Найти и заменить
Поиск — один запрос, замена — два, и первый книгу не меняет: ядро вычисляет план правок, а пишет их та же дорога, что ввод.

В рамке выше работает тот самый проект, чей код лежит ниже, — собранный, но не переписанный: те же файлы, только пропущенные через сборку. Считает в нём не ядро, а дублёр транспорта, поэтому числа в ячейках ничего не значат: смотреть нужно на код.
Страница, которую открывает браузер.
<!doctype html><!-- Найти и заменить — НАСТОЯЩИЙ МАЛЕНЬКИЙ ПРОЕКТ.
Не «страница витрины», а тот проект, который встраивающий копирует к себе целиком. Галерея примеров ЗАПУСКАЕТ эти файлы и ПОКАЗЫВАЕТ их же.--><html> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Найти и заменить — @f1</title> </head> <body> <div data-example="find-replace" class="flex h-screen w-screen flex-col bg-base-100 text-base-content"> <header class="border-b border-base-300 px-4 py-2"> <div class="text-sm font-semibold">Найти и заменить</div> <div class="text-xs opacity-70"> Поиск — ОДИН запрос (<code>find_in_workbook_subset</code>). Замена — ДВА, и первый ничего не меняет: <code>compute_replacements_in_workbook_subset</code> ВЫЧИСЛЯЕТ план правок, а записывает их та же дорога, что ввод с клавиатуры. Ни одного сравнения строк здесь нет: регистр, «ячейка целиком», подстановочные знаки и порядок обхода — за швом. </div> </header>
<section class="m-4 flex flex-col gap-3 rounded-card border border-base-300 p-4" data-find-panel> <div class="flex flex-wrap items-end gap-2"> <!-- Подписи полей — Excel для macOS, окно «Найти и заменить» (те же строки, что у нашего окна: `bodies/find-replace.tsx`). --> <label class="form-control"> <span class="label-text text-xs">Найти:</span> <input data-find-what value="2025" class="input input-sm input-bordered w-40" /> </label> <label class="form-control"> <span class="label-text text-xs">Заменить на:</span> <input data-find-replacement value="2026" class="input input-sm input-bordered w-40" /> </label> <label class="label cursor-pointer gap-2"> <input data-find-case type="checkbox" class="checkbox checkbox-sm" /> <span class="label-text text-xs">Учитывать регистр</span> </label> <label class="label cursor-pointer gap-2"> <input data-find-whole type="checkbox" class="checkbox checkbox-sm" /> <span class="label-text text-xs">Ячейка целиком</span> </label> <button data-find-all class="btn btn-sm btn-primary">Найти все</button> <button data-find-replace-all class="btn btn-sm">Заменить все</button> </div>
<!-- Строка состояния Excel: «ячеек найдено: N». Число — СЧЁТ ЯДРА, а не длина показанного списка. --> <div data-find-status class="text-xs opacity-60">наберите, что искать</div> <div data-find-matches class="flex flex-col gap-1 text-xs"></div> </section>
<div id="app" class="flex min-h-0 flex-1 flex-col"></div> </div>
<script type="module" src="./src/main.ts"></script> </body></html>Точка входа: здесь лист создаётся и сюда же приходят события.
import { createSheetsCore } from "@f1/preset-sheets-core";import { EXECUTOR, transport } from "./book.js";import { hostAsks } from "./host.js";import "./styles.css";
/** * НАЙТИ И ЗАМЕНИТЬ ЧЕРЕЗ SDK — И ГЛАВНОЕ ЗДЕСЬ ЧИСЛО ЗАПРОСОВ. * * Поиск стоит ОДИН запрос. Замена — ДВА, и первый книгу не меняет: ядро * ВЫЧИСЛЯЕТ план (`computeReplacementsAsync`) со старым и новым текстом каждой * ячейки, а записывает его `applyReplacementsAsync` — тем же `apply_cell_edits`, * которым идёт ввод с клавиатуры. Своей дороги записи в книгу у замены нет, и * это инвариант №2 в одну строку: второй владелец книги не заводится ради * удобства. * * ЧЕГО ЗДЕСЬ НЕТ И ЭТО НАЗВАНО: ни одного сравнения строк. Регистр, «ячейка * целиком», подстановочные знаки `*`/`?` и порядок обхода живут в ядре — отсюда * уходит только намерение. Поэтому галочки на странице ничего не «фильтруют»: * они уезжают полями запроса. * * И ЕЩЁ ОДНОГО НЕТ НАРОЧНО: перевода курсора листа на найденную ячейку. * Выделение принадлежит сетке, и объявлять его командой продукт запрещает прямо. */const { locale } = hostAsks();
const what = document.querySelector<HTMLInputElement>("[data-find-what]")!;const replacement = document.querySelector<HTMLInputElement>("[data-find-replacement]")!;const matchCase = document.querySelector<HTMLInputElement>("[data-find-case]")!;const matchEntireCell = document.querySelector<HTMLInputElement>("[data-find-whole]")!;const status = document.querySelector<HTMLElement>("[data-find-status]")!;const rows = document.querySelector<HTMLElement>("[data-find-matches]")!;
const core = createSheetsCore({ mount: document.getElementById("app")!, transport, locale });const workbook = core.f1.getActiveWorkbook();
const options = () => ({ scope: "workbook" as const, matchCase: matchCase.checked, matchEntireCell: matchEntireCell.checked,});
function say(line: string, bad = false): void { status.className = bad ? "text-xs text-error" : "text-xs opacity-70"; status.textContent = line;}
document.querySelector("[data-find-all]")!.addEventListener("click", () => { void workbook.findAsync(what.value, options()).then((found) => { if (!found.ok) { status.setAttribute("data-find-refusal", found.code ?? "refused"); say(`Найти все: отказ — ${found.reason}${found.code ? ` (${found.code})` : ""}`, true); return; } status.removeAttribute("data-find-refusal"); /* Число — СЧЁТ ЯДРА (`totalMatchCount`), а не длина показанного списка: ядро вправе прислать первую страницу совпадений, и подмена счёта длиной списка соврала бы ровно на границе страницы. */ status.setAttribute("data-find-count", String(found.totalMatchCount)); say( `ячеек найдено: ${found.totalMatchCount} — сосчитано ${EXECUTOR} ` + `(искало в «${found.lookIn}», порядок «${found.searchOrder}»)`, ); rows.replaceChildren( ...found.matches.map((match) => { const row = document.createElement("div"); row.setAttribute("data-find-match", `${match.sheetName}!${match.address}`); row.className = "rounded border border-base-300 px-2 py-1"; row.textContent = `${match.sheetName}!${match.address} · ${match.value}`; return row; }), ); });});
document.querySelector("[data-find-replace-all]")!.addEventListener("click", () => { /* ДВА ВЫЗОВА, И ЭТО ВИДНО В КОДЕ: сначала план — книга ещё та же, потом запись. Слить их в один метод значило бы спрятать самое важное свойство этой дороги. */ void workbook.computeReplacementsAsync(what.value, replacement.value, options()).then(async (plan) => { if (!plan.ok) { status.setAttribute("data-find-refusal", plan.code ?? "refused"); say(`Заменить все: отказ — ${plan.reason}${plan.code ? ` (${plan.code})` : ""}`, true); return; } status.removeAttribute("data-find-refusal"); const written = await workbook.applyReplacementsAsync(plan.edits); if (typeof written !== "number") { say(`Заменить все: план получен, а запись отказана — ${written.reason}`, true); return; } status.setAttribute("data-find-replaced", String(written)); say( `заменено ячеек: ${written} — план вычислен ${EXECUTOR} (${plan.totalEditCount} правок), ` + `записан обычным apply_cell_edits`, ); rows.replaceChildren( ...plan.edits.map((edit) => { const row = document.createElement("div"); row.setAttribute("data-find-edit", `${edit.sheetName}!${edit.address}`); row.className = "rounded border border-base-300 px-2 py-1"; row.textContent = `${edit.sheetName}!${edit.address} · «${edit.oldValue}» → «${edit.newValue}»`; return row; }), ); });});
/* Первый поиск задаётся сам — чтобы страница показывала ОТВЕТ, а не пустую форму: ищет при этом ядро, и в подписи назван именно исполнитель. */void core.f1.loadAsync().then(() => { (document.querySelector("[data-find-all]") as HTMLButtonElement).click();});
import.meta.hot?.dispose(() => core.destroy());Книга примера: данные, с которыми лист открывается.
import { createTransport, transportExecutorName, transportSettingFromSearch } from "@f1/contract";
/** * КНИГА-ЗАГЛУШКА — ВМЕСТО НАСТОЯЩЕГО ЯДРА. * * В книге нарочно есть и «2025» в разных падежах места, и слово, отличающееся * регистром: сравнение строк, «ячейка целиком» и подстановочные знаки живут в * ядре, и увидеть это можно только на настоящих данных. * * ТРАНСПОРТ ВЫБИРАЕТСЯ НАСТРОЙКОЙ: умолчание — заглушка, * `?core=http://127.0.0.1:5507` уводит на живое ядро. Код примера не меняется. */const TABLE: readonly (readonly string[])[] = [ ["Отчёт", "План 2025", "Факт 2025"], ["Север", "18 480", "17 900"], ["Восток", "9 110", "9 110"], ["Итого 2025", "27 590", "27 010"],];
const setting = transportSettingFromSearch(globalThis.location?.search ?? "", { sheets: ["Лист1"], usedRange: { rowEnd: 3, colEnd: 2 }, cellAt: (row, col) => TABLE[row]?.[col],});
export const transport = createTransport(setting);
/** * КТО ИЩЕТ И КТО ЗАМЕНЯЕТ — и почему это написано рядом с транспортом. Ни одного * сравнения строк в оболочке нет: регистр, «ячейка целиком», подстановочные * знаки и порядок обхода принадлежат ядру. Подпись под ответом обязана называть * исполнителя правдиво и на заглушке, и на живом ядре. */export const EXECUTOR = transportExecutorName(setting);Сторона хозяина страницы: что приложение отвечает листу и что от него получает.
import { applyLocaleAttributes, resolveLocale, setLocale, type Locale } from "@f1/i18n";
/** * ЧТО ХОЗЯИН СТРАНИЦЫ ДАЁТ ЛИСТУ — тема и язык. * * У встраивающего это уже есть своё: тему он знает из настроек, язык — из * профиля пользователя. Здесь и то и другое читается из адреса * (`?theme=dark&locale=ar`) ровно затем, чтобы на любое состояние можно было * дать ссылку и чтобы его проверял обход: настройка, доступная только щелчком, * недоступна ни ссылке, ни автоматической проверке. * * ПРИЗНАКИ СТАВЯТСЯ НА КОРЕНЬ ДОКУМЕНТА — потому что это делает ХОЗЯИН, а не * лист. Лист читает тему оттуда, где стоит (`:host-context`), а язык получает * СВОЙСТВОМ сборки: два листа на одной странице вправе говорить на разном. * * НАПРАВЛЕНИЕ ПИСЬМА — ИНТЕРФЕЙСА, А НЕ ДОКУМЕНТА. `dir="rtl"` зеркалит хром * страницы; «Лист справа налево» из Excel — свойство КНИГИ, и сетка от * арабского языка интерфейса не переворачивается. Это не пропуск, а решение * (`locale.ts`). */export function hostAsks(): { readonly theme: "light" | "dark"; readonly locale: Locale } { const params = new URLSearchParams(location.search); const theme = params.get("theme") === "dark" ? "dark" : "light"; const locale = resolveLocale(params.get("locale") ?? undefined, "ru");
document.documentElement.setAttribute("data-theme", theme); /* Язык — один на процесс: словарь модульный, и ставит его хозяин страницы. */ setLocale(locale); applyLocaleAttributes(document.documentElement, locale);
return { theme, locale };}Оформление страницы вокруг листа.
/* ЛИСТ СТИЛЕЙ НАБОРА КОМПОНЕНТОВ — ЕДИНСТВЕННАЯ СТРОКА, КОТОРУЮ НЕЛЬЗЯ ЗАБЫТЬ. Собственное оформление лист приносит в теневые корни сам, но утилиты раскладки, которыми он держит свои органы, компилируются ЭТИМ листом. Без него страница не просто выглядит иначе — вкладка ЗАВИСАЕТ (бесконечный цикл замера у сетки без размеров). У потребителя вне репозитория это `@fastoffices/formula-kit` из его node_modules. */@import "@fastoffices/formula-kit"; /* см. примечание выше */html,body { height: 100%; margin: 0;}Из каких пакетов собран пример.
{ "name": "f1-example-find-replace", "private": true, "type": "module", "description": "Найти и заменить: поиск — один запрос, замена — два, и первый книгу не меняет.", "scripts": { "dev": "vite", "build": "vite build" }, "dependencies": { "@f1/preset-sheets-core": "*", "@f1/contract": "*", "@f1/i18n": "*" }, "devDependencies": { "vite": "^7.0.0" }}- Остальные примеры — Примеры F1.
- Правила, на которые ссылается код, — пять правил.
- Из чего собирается лист — Архитектура.
- Что можно вызвать сегодня — справочник Compute Contract.