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:
Horizontal42 2026-07-03 11:57:33 +03:00
parent 8b4e6fc0fe
commit 5af7c66c3c
3 changed files with 261 additions and 192 deletions

View file

@ -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
View 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
View 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();
},
};
}