Перейти к содержимому
FastOffice

Найти и заменить

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

поиск — один запрос, замена — два, и первый книгу не меняет: ядро вычисляет план правок, а пишет их та же дорога, что ввод
открыть в новой вкладке ↗Работающий пример. Ниже — его код целиком, файл за файлом.

В рамке выше работает тот самый проект, чей код лежит ниже, — собранный, но не переписанный: те же файлы, только пропущенные через сборку. Считает в нём не ядро, а дублёр транспорта, поэтому числа в ячейках ничего не значат: смотреть нужно на код.

Страница, которую открывает браузер.

index.html
<!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>

Точка входа: здесь лист создаётся и сюда же приходят события.

src/main.ts
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());

Книга примера: данные, с которыми лист открывается.

src/book.ts
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);

Сторона хозяина страницы: что приложение отвечает листу и что от него получает.

src/host.ts
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 };
}

Оформление страницы вокруг листа.

src/styles.css
/* ЛИСТ СТИЛЕЙ НАБОРА КОМПОНЕНТОВ — ЕДИНСТВЕННАЯ СТРОКА, КОТОРУЮ НЕЛЬЗЯ ЗАБЫТЬ.
Собственное оформление лист приносит в теневые корни сам, но утилиты
раскладки, которыми он держит свои органы, компилируются ЭТИМ листом. Без
него страница не просто выглядит иначе — вкладка ЗАВИСАЕТ (бесконечный цикл
замера у сетки без размеров). У потребителя вне репозитория это
`@fastoffices/formula-kit` из его node_modules. */
@import "@fastoffices/formula-kit"; /* см. примечание выше */
html,
body {
height: 100%;
margin: 0;
}

Из каких пакетов собран пример.

package.json
{
"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"
}
}

Ассистент документации

Ответ собран из документации и может быть неточным — сверяйтесь с источниками.