mirror of
https://github.com/Horizontal42/summarum.git
synced 2026-11-11 01:02:01 +03:00
refactor: pull cross-sheet search and PNG export out of main.ts
Both were self-contained enough to live in their own modules — src/ui/search.ts (Ctrl+F overlay, text and result-value queries) and src/ui/export.ts (render the sheet + results to a PNG, clipboard or file). Trims main.ts by about 150 lines. Also fixes a real bug while it's in there: the PNG export's clipboard fallback did btoa(String.fromCharCode(...bytes)), which blows the call stack on large sheets (spreading a big array as call arguments). Chunks the conversion in 0x8000-byte pieces instead.
This commit is contained in:
parent
8b4e6fc0fe
commit
5af7c66c3c
3 changed files with 261 additions and 192 deletions
217
src/main.ts
217
src/main.ts
|
|
@ -5,7 +5,7 @@ import { SumEditor } from "./ui/editor";
|
||||||
import {
|
import {
|
||||||
AppData, DocMeta, SettingsData, defaultSettingsData,
|
AppData, DocMeta, SettingsData, defaultSettingsData,
|
||||||
loadAppData, saveAppData, flushAppData, onAppQuit, loadSettings, saveSettings,
|
loadAppData, saveAppData, flushAppData, onAppQuit, loadSettings, saveSettings,
|
||||||
fetchRates, fetchMarketData, fetchHistoricalRates, writeImageFile, loadExtensionScripts, openExtensionsFolder, isTauri,
|
fetchRates, fetchMarketData, fetchHistoricalRates, loadExtensionScripts, openExtensionsFolder, isTauri,
|
||||||
getLaunchFile, onOpenFile, onFileDrop,
|
getLaunchFile, onOpenFile, onFileDrop,
|
||||||
setDataDir, runBackups, backupDeletedSheet, openBackupsFolder,
|
setDataDir, runBackups, backupDeletedSheet, openBackupsFolder,
|
||||||
chooseFolder, dataDirHasDocuments, migrateDataDir,
|
chooseFolder, dataDirHasDocuments, migrateDataDir,
|
||||||
|
|
@ -16,6 +16,8 @@ import { runExtensions } from "./extensions";
|
||||||
import { checkForUpdate } from "./updater";
|
import { checkForUpdate } from "./updater";
|
||||||
import { Workspace } from "./workspace";
|
import { Workspace } from "./workspace";
|
||||||
import { EN, RU } from "./engine/vocab-data";
|
import { EN, RU } from "./engine/vocab-data";
|
||||||
|
import { exportSheetImage } from "./ui/export";
|
||||||
|
import { initSearch, SearchController } from "./ui/search";
|
||||||
|
|
||||||
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"];
|
||||||
|
|
@ -27,6 +29,7 @@ const $ = <T extends HTMLElement>(sel: string): T => document.querySelector(sel)
|
||||||
let engine: SumEngine;
|
let engine: SumEngine;
|
||||||
let editor: SumEditor;
|
let editor: SumEditor;
|
||||||
let workspace: Workspace;
|
let workspace: Workspace;
|
||||||
|
let search: SearchController;
|
||||||
let settings: SettingsData;
|
let settings: SettingsData;
|
||||||
let data: AppData;
|
let data: AppData;
|
||||||
let lastResults: LineResult[] = [];
|
let lastResults: LineResult[] = [];
|
||||||
|
|
@ -179,113 +182,6 @@ async function closeActiveDoc(): Promise<void> {
|
||||||
saveAppData(data);
|
saveAppData(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- search all sheets
|
|
||||||
|
|
||||||
interface SearchHit {
|
|
||||||
docId: string;
|
|
||||||
docTitle: string;
|
|
||||||
line: number;
|
|
||||||
text: string;
|
|
||||||
result?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseResultQuery(q: string): { op: string; threshold: import("./engine").Value } | null {
|
|
||||||
const m = /^(>=|<=|>|<|=|~)\s*(.+)$/.exec(q.trim());
|
|
||||||
if (!m) return null;
|
|
||||||
const v = engine.evaluateExpression(m[2].trim());
|
|
||||||
if (!v || v.kind !== "quantity") return null;
|
|
||||||
return { op: m[1], threshold: v };
|
|
||||||
}
|
|
||||||
|
|
||||||
function searchAllSheets(query: string): SearchHit[] {
|
|
||||||
const q = query.trim();
|
|
||||||
if (!q) return [];
|
|
||||||
const rq = parseResultQuery(q);
|
|
||||||
if (rq && rq.threshold.kind === "quantity") {
|
|
||||||
const th = rq.threshold.value;
|
|
||||||
const hits: SearchHit[] = [];
|
|
||||||
for (const doc of data.docs) {
|
|
||||||
const contents = data.contents[doc.id] ?? "";
|
|
||||||
const results = workspace.evaluateSheet(doc.id, contents);
|
|
||||||
const lines = contents.split("\n");
|
|
||||||
for (let i = 0; i < results.length; i++) {
|
|
||||||
const r = results[i];
|
|
||||||
if (!r.value || r.value.kind !== "quantity") continue;
|
|
||||||
const v = r.value.value;
|
|
||||||
let match = false;
|
|
||||||
switch (rq.op) {
|
|
||||||
case ">": match = v.gt(th); break;
|
|
||||||
case ">=": match = v.gte(th); break;
|
|
||||||
case "<": match = v.lt(th); break;
|
|
||||||
case "<=": match = v.lte(th); break;
|
|
||||||
case "=": match = v.eq(th); break;
|
|
||||||
case "~": match = !th.isZero() && v.minus(th).abs().div(th.abs()).lte(0.01); break;
|
|
||||||
}
|
|
||||||
if (match) {
|
|
||||||
hits.push({ docId: doc.id, docTitle: doc.title, line: i + 1, text: lines[i] ?? "", result: r.text ?? undefined });
|
|
||||||
if (hits.length >= 200) return hits;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return hits;
|
|
||||||
}
|
|
||||||
const ql = q.toLowerCase();
|
|
||||||
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(ql)) {
|
|
||||||
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.result ? `${hit.text} = ${hit.result}` : 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 {
|
||||||
|
|
@ -649,72 +545,6 @@ function renderRatesInfo(): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- PNG export
|
|
||||||
|
|
||||||
async function renderSheetImage(): Promise<void> {
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
|
||||||
const style = getComputedStyle(document.documentElement);
|
|
||||||
const bgColor = style.getPropertyValue("--bg").trim() || "#ffffff";
|
|
||||||
const fgColor = style.getPropertyValue("--fg").trim() || "#333333";
|
|
||||||
const resultColor = style.getPropertyValue("--result").trim() || "#d57d2c";
|
|
||||||
const monoFont = style.getPropertyValue("--mono").trim() || "monospace";
|
|
||||||
const fontSize = settings.fontSize;
|
|
||||||
const lineH = Math.round(fontSize * 1.7);
|
|
||||||
const padX = 20, padTop = 16, padBot = 16;
|
|
||||||
|
|
||||||
const doc = workspace.evaluateSheet(activeDoc().id, editor.getText());
|
|
||||||
const rawLines = editor.getText().split("\n");
|
|
||||||
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
const ctx2 = canvas.getContext("2d")!;
|
|
||||||
ctx2.font = `${fontSize}px ${monoFont}`;
|
|
||||||
const maxWidth = rawLines.reduce((m, l) => Math.max(m, ctx2.measureText(l).width), 0) + padX * 4 + 150;
|
|
||||||
const w = Math.max(400, maxWidth);
|
|
||||||
const h = padTop + rawLines.length * lineH + padBot;
|
|
||||||
|
|
||||||
canvas.width = w * dpr;
|
|
||||||
canvas.height = h * dpr;
|
|
||||||
ctx2.scale(dpr, dpr);
|
|
||||||
|
|
||||||
ctx2.fillStyle = bgColor;
|
|
||||||
ctx2.fillRect(0, 0, w, h);
|
|
||||||
|
|
||||||
ctx2.font = `${fontSize}px ${monoFont}`;
|
|
||||||
for (let i = 0; i < rawLines.length; i++) {
|
|
||||||
const y = padTop + i * lineH + fontSize;
|
|
||||||
ctx2.fillStyle = fgColor;
|
|
||||||
ctx2.fillText(rawLines[i], padX, y);
|
|
||||||
const res = doc[i]?.text;
|
|
||||||
if (res) {
|
|
||||||
ctx2.fillStyle = resultColor;
|
|
||||||
const rx = w - padX - ctx2.measureText(res).width;
|
|
||||||
ctx2.fillText(res, rx, y);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await new Promise<Blob | null>((res) => canvas.toBlob(res, "image/png"));
|
|
||||||
if (!blob) { toast(t("imageFailed")); return; }
|
|
||||||
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
|
|
||||||
toast(t("imageCopied"));
|
|
||||||
} catch {
|
|
||||||
// clipboard API failed — save to file
|
|
||||||
try {
|
|
||||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
|
||||||
const path = await save({ filters: [{ name: "PNG Image", extensions: ["png"] }] });
|
|
||||||
if (path) {
|
|
||||||
const buf = await blob.arrayBuffer();
|
|
||||||
const b64 = btoa(String.fromCharCode(...new Uint8Array(buf)));
|
|
||||||
await writeImageFile(path, b64);
|
|
||||||
toast(t("saved"));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast(t("imageFailed"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- modal
|
// ---------- modal
|
||||||
|
|
||||||
/** two-button modal; resolves "a" | "b" | null (click outside = cancel) */
|
/** two-button modal; resolves "a" | "b" | null (click outside = cancel) */
|
||||||
|
|
@ -821,6 +651,17 @@ async function boot(): Promise<void> {
|
||||||
// so a reopened sheet with "on 2024-01-01" needs its own historical-rate fetch
|
// so a reopened sheet with "on 2024-01-01" needs its own historical-rate fetch
|
||||||
void fetchNeededHistoricalRates(data.contents[data.activeId] ?? "");
|
void fetchNeededHistoricalRates(data.contents[data.activeId] ?? "");
|
||||||
|
|
||||||
|
search = initSearch({
|
||||||
|
engine,
|
||||||
|
workspace,
|
||||||
|
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);
|
||||||
|
editor.goToLine(line);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
applySettings(); // sets the language before bindSettingsUI renders dynamic labels
|
applySettings(); // sets the language before bindSettingsUI renders dynamic labels
|
||||||
bindSettingsUI();
|
bindSettingsUI();
|
||||||
renderDocList();
|
renderDocList();
|
||||||
|
|
@ -858,21 +699,7 @@ async function boot(): Promise<void> {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#open-search").addEventListener("click", () => openSearch());
|
$("#open-search").addEventListener("click", () => search.open());
|
||||||
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");
|
||||||
|
|
@ -942,9 +769,9 @@ async function boot(): Promise<void> {
|
||||||
}
|
}
|
||||||
if (e.ctrlKey && !e.shiftKey && e.key.toLowerCase() === "f") {
|
if (e.ctrlKey && !e.shiftKey && e.key.toLowerCase() === "f") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openSearch();
|
search.open();
|
||||||
}
|
}
|
||||||
if (e.ctrlKey && e.shiftKey && e.code === "KeyC") {
|
if (e.ctrlKey && e.shiftKey && e.code === "KeyC") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void navigator.clipboard.writeText(editor.getSheetWithResults());
|
void navigator.clipboard.writeText(editor.getSheetWithResults());
|
||||||
toast(t("copied"));
|
toast(t("copied"));
|
||||||
|
|
@ -965,7 +792,13 @@ async function boot(): Promise<void> {
|
||||||
exportMenu.classList.add("hidden");
|
exportMenu.classList.add("hidden");
|
||||||
const action = btn.dataset.action;
|
const action = btn.dataset.action;
|
||||||
if (action === "image") {
|
if (action === "image") {
|
||||||
void renderSheetImage();
|
void exportSheetImage({
|
||||||
|
lines: editor.getText().split("\n"),
|
||||||
|
results: workspace.evaluateSheet(activeDoc().id, editor.getText()),
|
||||||
|
fontSize: settings.fontSize,
|
||||||
|
toast,
|
||||||
|
t,
|
||||||
|
});
|
||||||
} else if (action === "copy") {
|
} else if (action === "copy") {
|
||||||
void navigator.clipboard.writeText(editor.getSheetWithResults());
|
void navigator.clipboard.writeText(editor.getSheetWithResults());
|
||||||
toast(t("copied"));
|
toast(t("copied"));
|
||||||
|
|
|
||||||
82
src/ui/export.ts
Normal file
82
src/ui/export.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
// Renders the active sheet + its results to a PNG and copies it to the
|
||||||
|
// clipboard, falling back to a Save As dialog when the Clipboard API is
|
||||||
|
// unavailable (e.g. the window lost focus).
|
||||||
|
import { writeImageFile } from "../storage";
|
||||||
|
|
||||||
|
export interface ExportImageDeps {
|
||||||
|
lines: string[];
|
||||||
|
results: { text: string | null }[];
|
||||||
|
fontSize: number;
|
||||||
|
toast(msg: string): void;
|
||||||
|
t(key: string): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportSheetImage(deps: ExportImageDeps): Promise<void> {
|
||||||
|
const { lines: rawLines, results: doc, fontSize, toast, t } = deps;
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const style = getComputedStyle(document.documentElement);
|
||||||
|
const bgColor = style.getPropertyValue("--bg").trim() || "#ffffff";
|
||||||
|
const fgColor = style.getPropertyValue("--fg").trim() || "#333333";
|
||||||
|
const resultColor = style.getPropertyValue("--result").trim() || "#d57d2c";
|
||||||
|
const monoFont = style.getPropertyValue("--mono").trim() || "monospace";
|
||||||
|
const lineH = Math.round(fontSize * 1.7);
|
||||||
|
const padX = 20, padTop = 16, padBot = 16;
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
const ctx2 = canvas.getContext("2d")!;
|
||||||
|
ctx2.font = `${fontSize}px ${monoFont}`;
|
||||||
|
const maxWidth = rawLines.reduce((m, l) => Math.max(m, ctx2.measureText(l).width), 0) + padX * 4 + 150;
|
||||||
|
const w = Math.max(400, maxWidth);
|
||||||
|
const h = padTop + rawLines.length * lineH + padBot;
|
||||||
|
|
||||||
|
canvas.width = w * dpr;
|
||||||
|
canvas.height = h * dpr;
|
||||||
|
ctx2.scale(dpr, dpr);
|
||||||
|
|
||||||
|
ctx2.fillStyle = bgColor;
|
||||||
|
ctx2.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
|
ctx2.font = `${fontSize}px ${monoFont}`;
|
||||||
|
for (let i = 0; i < rawLines.length; i++) {
|
||||||
|
const y = padTop + i * lineH + fontSize;
|
||||||
|
ctx2.fillStyle = fgColor;
|
||||||
|
ctx2.fillText(rawLines[i], padX, y);
|
||||||
|
const res = doc[i]?.text;
|
||||||
|
if (res) {
|
||||||
|
ctx2.fillStyle = resultColor;
|
||||||
|
const rx = w - padX - ctx2.measureText(res).width;
|
||||||
|
ctx2.fillText(res, rx, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await new Promise<Blob | null>((res) => canvas.toBlob(res, "image/png"));
|
||||||
|
if (!blob) { toast(t("imageFailed")); return; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
|
||||||
|
toast(t("imageCopied"));
|
||||||
|
} catch {
|
||||||
|
// clipboard API failed — save to file
|
||||||
|
try {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
const path = await save({ filters: [{ name: "PNG Image", extensions: ["png"] }] });
|
||||||
|
if (path) {
|
||||||
|
const buf = await blob.arrayBuffer();
|
||||||
|
const b64 = bytesToBase64(new Uint8Array(buf));
|
||||||
|
await writeImageFile(path, b64);
|
||||||
|
toast(t("saved"));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast(t("imageFailed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** chunked to avoid "Maximum call stack size exceeded" from spreading a large array as args */
|
||||||
|
function bytesToBase64(bytes: Uint8Array): string {
|
||||||
|
let bin = "";
|
||||||
|
for (let i = 0; i < bytes.length; i += 0x8000) {
|
||||||
|
bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
||||||
|
}
|
||||||
|
return btoa(bin);
|
||||||
|
}
|
||||||
154
src/ui/search.ts
Normal file
154
src/ui/search.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
// Full-text and numeric ("> 100", "~ 50") search across every open sheet.
|
||||||
|
// Owns the #search-overlay DOM and its own input debounce.
|
||||||
|
import { SumEngine, Value } from "../engine";
|
||||||
|
import { Workspace } from "../workspace";
|
||||||
|
|
||||||
|
export interface SearchHit {
|
||||||
|
docId: string;
|
||||||
|
docTitle: string;
|
||||||
|
line: number;
|
||||||
|
text: string;
|
||||||
|
result?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchDoc {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchDeps {
|
||||||
|
engine: SumEngine;
|
||||||
|
workspace: Workspace;
|
||||||
|
docs(): SearchDoc[];
|
||||||
|
t(key: string): string;
|
||||||
|
/** switch to docId (if not already active) and put the caret on line (1-based) */
|
||||||
|
onOpen(docId: string, line: number): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchController {
|
||||||
|
open(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const $ = <T extends HTMLElement>(sel: string): T => document.querySelector(sel) as T;
|
||||||
|
|
||||||
|
function parseResultQuery(engine: SumEngine, q: string): { op: string; threshold: Value } | null {
|
||||||
|
const m = /^(>=|<=|>|<|=|~)\s*(.+)$/.exec(q.trim());
|
||||||
|
if (!m) return null;
|
||||||
|
const v = engine.evaluateExpression(m[2].trim());
|
||||||
|
if (!v || v.kind !== "quantity") return null;
|
||||||
|
return { op: m[1], threshold: v };
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchAllSheets(deps: SearchDeps, query: string): SearchHit[] {
|
||||||
|
const q = query.trim();
|
||||||
|
if (!q) return [];
|
||||||
|
const docs = deps.docs();
|
||||||
|
const rq = parseResultQuery(deps.engine, q);
|
||||||
|
if (rq && rq.threshold.kind === "quantity") {
|
||||||
|
const th = rq.threshold.value;
|
||||||
|
const hits: SearchHit[] = [];
|
||||||
|
for (const doc of docs) {
|
||||||
|
const results = deps.workspace.evaluateSheet(doc.id, doc.text);
|
||||||
|
const lines = doc.text.split("\n");
|
||||||
|
for (let i = 0; i < results.length; i++) {
|
||||||
|
const r = results[i];
|
||||||
|
if (!r.value || r.value.kind !== "quantity") continue;
|
||||||
|
const v = r.value.value;
|
||||||
|
let match = false;
|
||||||
|
switch (rq.op) {
|
||||||
|
case ">": match = v.gt(th); break;
|
||||||
|
case ">=": match = v.gte(th); break;
|
||||||
|
case "<": match = v.lt(th); break;
|
||||||
|
case "<=": match = v.lte(th); break;
|
||||||
|
case "=": match = v.eq(th); break;
|
||||||
|
case "~": match = !th.isZero() && v.minus(th).abs().div(th.abs()).lte(0.01); break;
|
||||||
|
}
|
||||||
|
if (match) {
|
||||||
|
hits.push({ docId: doc.id, docTitle: doc.title, line: i + 1, text: lines[i] ?? "", result: r.text ?? undefined });
|
||||||
|
if (hits.length >= 200) return hits;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
const ql = q.toLowerCase();
|
||||||
|
const hits: SearchHit[] = [];
|
||||||
|
for (const doc of docs) {
|
||||||
|
const lines = doc.text.split("\n");
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (lines[i].toLowerCase().includes(ql)) {
|
||||||
|
hits.push({ docId: doc.id, docTitle: doc.title, line: i + 1, text: lines[i] });
|
||||||
|
if (hits.length >= 200) return hits;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initSearch(deps: SearchDeps): SearchController {
|
||||||
|
const overlay = $("#search-overlay");
|
||||||
|
const input = $<HTMLInputElement>("#search-input");
|
||||||
|
const resultsEl = $("#search-results");
|
||||||
|
|
||||||
|
function close(): void {
|
||||||
|
overlay.classList.add("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(query: string, hits: SearchHit[]): void {
|
||||||
|
resultsEl.replaceChildren();
|
||||||
|
if (query.trim() && hits.length === 0) {
|
||||||
|
const empty = document.createElement("div");
|
||||||
|
empty.className = "search-empty";
|
||||||
|
empty.textContent = deps.t("searchEmpty");
|
||||||
|
resultsEl.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.result ? `${hit.text} = ${hit.result}` : hit.text;
|
||||||
|
item.append(docEl, lineEl);
|
||||||
|
item.addEventListener("click", () => {
|
||||||
|
deps.onOpen(hit.docId, hit.line);
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
frag.appendChild(item);
|
||||||
|
}
|
||||||
|
resultsEl.appendChild(frag);
|
||||||
|
}
|
||||||
|
|
||||||
|
// a numeric query re-evaluates every open sheet — debounce so fast typing
|
||||||
|
// doesn't recompute the whole workspace on every keystroke
|
||||||
|
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
input.addEventListener("input", () => {
|
||||||
|
if (searchTimer) clearTimeout(searchTimer);
|
||||||
|
const q = input.value;
|
||||||
|
searchTimer = setTimeout(() => render(q, searchAllSheets(deps, q)), 150);
|
||||||
|
});
|
||||||
|
input.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
close();
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
resultsEl.querySelector<HTMLElement>(".search-item")?.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
overlay.addEventListener("mousedown", (e) => {
|
||||||
|
if (e.target === overlay) close();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
open(): void {
|
||||||
|
overlay.classList.remove("hidden");
|
||||||
|
input.value = "";
|
||||||
|
render("", []);
|
||||||
|
input.focus();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue