Гиперссылка
Ссылка — свойство ячейки: одна операция ядра ставит её вместе с текстом, снимает, оставляя текст, и сама называет ссылки листа.

В рамке выше работает тот самый проект, чей код лежит ниже, — собранный, но не переписанный: те же файлы, только пропущенные через сборку. Считает в нём не ядро, а дублёр транспорта, поэтому числа в ячейках ничего не значат: смотреть нужно на код.
Страница, которую открывает браузер.
<!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="hyperlink" 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>mutate_table_hyperlink_artifacts</code>. Постановка пишет и ссылку, и текст ячейки одним шагом: у ячейки появляется поле <code>hyperlink</code>. Снятие это поле убирает, а ТЕКСТ оставляет — как в Excel. Своей семантики ссылки у нас нет: адрес проверяет ядро, и его отказ виден кодом и словами. </div> </header>
<section class="m-4 flex flex-col gap-3 rounded-card border border-base-300 p-4" data-hyperlink-panel> <!-- Подписи и порядок — Excel для macOS, окно «Вставка гиперссылки» (те же строки, что у нашего окна: `bodies/insert.tsx`). --> <div class="flex flex-wrap items-end gap-2"> <label class="form-control"> <span class="label-text text-xs">Связать с:</span> <select data-hyperlink-kind class="select select-sm select-bordered"> <option value="web" selected>файлом, веб-страницей</option> <option value="document">местом в документе</option> <option value="new-document">новым документом</option> <option value="email">электронной почтой</option> </select> </label> <label class="form-control"> <span class="label-text text-xs">Ячейка листа</span> <input data-hyperlink-cell value="A1" class="input input-sm input-bordered w-24" /> </label> <label class="form-control"> <span class="label-text text-xs">Текст:</span> <input data-hyperlink-text value="Документация" class="input input-sm input-bordered w-44" /> </label> <label class="form-control grow"> <span data-hyperlink-address-label class="label-text text-xs">Адрес:</span> <input data-hyperlink-address value="https://example.com/docs" class="input input-sm input-bordered w-full" /> </label> <!-- ПОДСКАЗКА — ПОЛЕ ЯДРА (`tooltip`), а не украшение страницы: ядро принимает её и возвращает в описи ссылок. --> <label class="form-control"> <span class="label-text text-xs">Подсказка:</span> <input data-hyperlink-tooltip value="" class="input input-sm input-bordered w-40" /> </label> <button data-hyperlink-apply class="btn btn-sm btn-primary">ОК</button> <!-- СНЯТЬ ССЫЛКУ, ОСТАВИВ ТЕКСТ. Кнопка стояла СЕРОЙ с подписью «снять гиперссылку ядро не умеет: операции гиперссылки в контракте нет» — операция в контракте есть, и причина стала ложью. Живая кнопка зовёт намерение `delete_hyperlink`. --> <button data-hyperlink-unlink class="btn btn-sm">Очистить гиперссылки</button> <!-- СПРОСИТЬ, НЕ ТРОГАЯ КНИГУ: намерение `cancel` ядра. Им же узнаётся номер правки книги — единственной законной дорогой. --> <button data-hyperlink-ask class="btn btn-sm btn-ghost">Что видит ядро</button> </div>
<!-- ЧТО УЙДЁТ В ЯДРО — ПОКАЗАНО ДО ОТПРАВКИ: это единственное, что делает оболочка, и прятать это было бы нечестно. --> <div data-hyperlink-request class="font-mono text-xs opacity-80"></div> <!-- ССЫЛКИ ЛИСТА СЛОВАМИ ЯДРА, а не нашей памятью. --> <div data-hyperlink-links class="text-xs opacity-70">ядро о ссылках ещё не спрашивали</div> <div data-hyperlink-answer class="text-xs opacity-60">ещё ничего не отправляли</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 { hyperlinkRefusal, hyperlinkTarget, type HyperlinkDraft, type HyperlinkKind } from "@f1/sheets";import { EXECUTOR, transport } from "./book.js";import { hostAsks } from "./host.js";import "./styles.css";
/** * ГИПЕРССЫЛКА — ЭТО СВОЙСТВО ЯЧЕЙКИ, И У КОНТРАКТА ЕСТЬ СВОЯ ОПЕРАЦИЯ ДЛЯ НЕЁ. * * ЗДЕСЬ ГОД СТОЯЛА НЕПРАВДА, И ОНА НАЗВАНА, А НЕ СТЁРТА: «ставить и снимать * нечем: операции гиперссылки в контракте нет НИ ОДНОЙ», поэтому пример * собирал строку `=HYPERLINK("адрес","подпись")` и слал её обычной правкой * ячейки. Формула и ссылка — разные вещи: у ячейки с формулой поля * `hyperlink` нет вовсе, Excel такую ячейку ссылкой не считает, и «Удалить * гиперссылку» над ней снимать нечего. Пример учил чужого разработчика * обходному пути вместо дороги. * * ОПЕРАЦИЯ У КОНТРАКТА ЕСТЬ — `mutate_table_hyperlink_artifacts`; в * последний опубликованный выпуск она пока не вошла, и обе стороны сняты ЖИВЫМ ЯДРОМ: * постановка отвечает `changedCellCount: 1` и ячейка становится * `{"value":"отчёт","hyperlink":{"target":"https://example.org/", …}}`; * снятие отвечает `ok`, поля `hyperlink` у ячейки БОЛЬШЕ НЕТ, а ТЕКСТ остался. * Ровно этой дорогой ходит и продукт («Вставка ▸ Ссылка», «Очистить * гиперссылки») — второй здесь не заводится (инвариант №2). * * ТРИ НАМЕРЕНИЯ SDK, И ПОРЯДОК У НИХ НЕ НАШ, А ЯДРА: * * · `read` — спросить состояние, не трогая книгу (`cancel` ядра). ЕДИНСТВЕННОЕ * намерение мимо сторожа ревизии, * поэтому им же узнают НОМЕР ПРАВКИ книги. Без этого шага «ОК» над только * что открытой книгой отказал бы сам, не доехав до ядра: номера правки ещё * никто не называл; * · `upsert` — поставить ссылку И написать текст той же ячейки одним шагом * (`cellText`): так делает ядро, и без текста ссылка легла бы на пустую * ячейку и была бы человеку не видна; * · `delete` — снять ссылку С ОДНОЙ ячейки; диапазона операция не принимает, * и это граница ядра, а не упрощение примера. * * СПИСОК ССЫЛОК ПОКАЗАН СЛОВАМИ ЯДРА. Что ссылка ПОЯВИЛАСЬ и что она ИСЧЕЗЛА, * страница берёт из ответа (`sheetHyperlinks`), а не из собственной памяти: * своего второго списка ссылок здесь нет и быть не должно. * * ПРАВИЛА АДРЕСА — ТОЖЕ НЕ НАШИ: схемы `http://`, `https://`, `mailto:`, * `file://` и внутридокументный `#` перечислены в f1, и проверка живёт ОДНА * (`hyperlinkRefusal`) — та же, что в окне продукта. Второй её копии здесь нет, * а последнее слово всё равно за ЯДРОМ: его отказ виден кодом и словами. * * ЧЕТЫРЕ ВИДА ССЫЛКИ — СПИСОК EXCEL, и три из них живые. «Новым документом» * отвечает ЧЕСТНЫМ «нет»: Excel по этой строке СОЗДАЁТ файл, а создавать файлы * продукт не умеет вовсе — ни операции, ни адресата. Живой «ОК» здесь был бы * обещанием файла, которого не будет. */const { locale } = hostAsks();
const kind = document.querySelector<HTMLSelectElement>("[data-hyperlink-kind]")!;const cell = document.querySelector<HTMLInputElement>("[data-hyperlink-cell]")!;const text = document.querySelector<HTMLInputElement>("[data-hyperlink-text]")!;const address = document.querySelector<HTMLInputElement>("[data-hyperlink-address]")!;const tooltip = document.querySelector<HTMLInputElement>("[data-hyperlink-tooltip]")!;const addressLabel = document.querySelector<HTMLElement>("[data-hyperlink-address-label]")!;const requestLine = document.querySelector<HTMLElement>("[data-hyperlink-request]")!;const answer = document.querySelector<HTMLElement>("[data-hyperlink-answer]")!;const links = document.querySelector<HTMLElement>("[data-hyperlink-links]")!;const apply = document.querySelector<HTMLButtonElement>("[data-hyperlink-apply]")!;const unlink = document.querySelector<HTMLButtonElement>("[data-hyperlink-unlink]")!;const ask = document.querySelector<HTMLButtonElement>("[data-hyperlink-ask]")!;
const core = createSheetsCore({ mount: document.getElementById("app")!, transport, locale });const sheet = core.f1.getActiveWorkbook().getActiveSheet()!;
const draft = (): HyperlinkDraft => ({ kind: kind.value as HyperlinkKind, address: address.value, text: text.value, sheetName: sheet.getName(),});
function display(target: string): string { return text.value.trim().length > 0 ? text.value.trim() : target;}
/** * ЧТО УЙДЁТ В ЯДРО — ПОКАЗАНО ДО ОТПРАВКИ, и «ОК» гаснет С ПРИЧИНОЙ. * * Серая кнопка обязана сказать, чего ждать: это то же правило, по которому * серыми стоят пункты ленты. Молчащая кнопка была бы хуже отсутствующей. */function redraw(): void { /* Подпись поля следует за видом ссылки — так в Excel: у почты спрашивают адрес почты, у места в документе — ячейку. */ addressLabel.textContent = kind.value === "email" ? "Адрес эл. почты:" : kind.value === "document" ? "Ячейка:" : "Адрес:";
const refusal = hyperlinkRefusal(draft()); const target = refusal === null ? hyperlinkTarget(draft()) : null; apply.disabled = target === null; apply.title = refusal ?? ""; requestLine.setAttribute("data-hyperlink-ready", String(target !== null)); requestLine.textContent = target === null ? `«ОК» не может: ${refusal ?? "нечего отправлять"}` : `mutate_table_hyperlink_artifacts · upsert_hyperlink · target: ${target} · ` + `display/cellText: ${display(target)}` + (tooltip.value.trim().length > 0 ? ` · tooltip: ${tooltip.value.trim()}` : "");}
for (const control of [kind, text, address, tooltip]) control.addEventListener("input", redraw);kind.addEventListener("change", redraw);
function cellAt(): { row: number; col: number } | null { const range = sheet.getRange(cell.value); if (!range) return null; return { row: range.getRow(), col: range.getColumn() };}
function say(line: string, bad = false): void { answer.className = bad ? "text-xs text-error" : "text-xs opacity-70"; answer.textContent = line;}
/** * ССЫЛКИ ЛИСТА СЛОВАМИ ЯДРА. Пустой список — это ОТВЕТ, а не «мы не нашли»: * ищет ссылки ядро, страница их только перечисляет. */function showLinks(entries: readonly { row: number; col: number; target: string; display?: string }[]): void { links.setAttribute("data-hyperlink-count", String(entries.length)); links.textContent = entries.length === 0 ? `ссылок на листе: 0 (счёт ${EXECUTOR})` : `ссылок на листе: ${entries.length} — ` + entries .map((one) => `строка ${one.row}, столбец ${one.col} → ${one.target}${one.display ? ` («${one.display}»)` : ""}`) .join("; ");}
/** * СПРОСИТЬ ЯДРО, НЕ ТРОГАЯ КНИГУ. Тем же намерением приезжает НОМЕР ПРАВКИ, и * поэтому оно же стоит первым шагом у «ОК» и у «Снять ссылку». */async function readLinks(): Promise<boolean> { const known = await sheet.mutateHyperlinksAsync({ action: "read", sheetKey: sheet.getName() }); if (!known.ok) { say(`Ядро отказало (${known.code ?? "без кода"}): ${known.reason}`, true); return false; } showLinks(known.sheetHyperlinks); return true;}
/** * ПОСЛЕ ПРАВКИ ОКНО ПЕРЕЧИТЫВАЕТСЯ, И ПРИЧИНА НАЗВАНА. * * Живое ядро присылает в том же ответе готовые к показу ячейки * (`previewCells`) — окно наполняется само. Заглушка их НЕ присылает, и без * перечитывания текст ссылки на стенде остался бы невидимым: страница * выглядела бы сломанной там, где ядро всё сделало. Перечитывание — честный * общий знаменатель двух повозок, а не подстановка значения своей рукой. */async function reread(): Promise<void> { await core.f1.loadAsync();}
apply.addEventListener("click", () => { const target = hyperlinkTarget(draft()); if (target === null) return; const at = cellAt(); if (!at) { say(`«${cell.value}» — не адрес ячейки`, true); return; } void (async () => { /* СНАЧАЛА СПРОСИТЬ, ПОТОМ СТАВИТЬ — правило ЯДРА, а не лишний шаг: постановка требует номера правки книги, и над только что открытой книгой ядро его ещё не называло. */ if (!(await readLinks())) return; const put = await sheet.mutateHyperlinksAsync({ action: "upsert", sheetKey: sheet.getName(), row: at.row, col: at.col, target, display: display(target), /* ТЕКСТ ЯЧЕЙКИ ПИШЕТ ТА ЖЕ ОПЕРАЦИЯ — правило ядра (`cellText`). */ cellText: display(target), ...(tooltip.value.trim().length > 0 ? { tooltip: tooltip.value.trim() } : {}), }); if (!put.ok) { say(`Ядро отказало (${put.code ?? "без кода"}): ${put.reason}`, true); return; } showLinks(put.sheetHyperlinks); await reread(); answer.setAttribute("data-hyperlink-sent", cell.value.toUpperCase()); say( `Ссылка поставлена ${EXECUTOR}: намерение «${put.action}», ячеек изменено ${put.changedCellCount}, ` + `номер правки ${put.revision ?? "не назван"}. У ячейки ${cell.value.toUpperCase()} появилось поле hyperlink, ` + `а текст в ней — «${sheet.getRange(cell.value)?.getValue() ?? ""}».`, ); })();});
/** * СНЯТЬ ССЫЛКУ, ОСТАВИВ ТЕКСТ. Кнопка стояла СЕРОЙ с причиной «снять * гиперссылку ядро не умеет» — причины больше нет, и это снято живым ядром, а * не решено на глаз. */unlink.addEventListener("click", () => { const at = cellAt(); if (!at) { say(`«${cell.value}» — не адрес ячейки`, true); return; } void (async () => { if (!(await readLinks())) return; const gone = await sheet.mutateHyperlinksAsync({ action: "delete", sheetKey: sheet.getName(), row: at.row, col: at.col, }); if (!gone.ok) { /* «Ссылки там нет» — тоже отказ ядра, и он показывается как отказ: `table-hyperlink-action-malformed`, подробность «hyperlink target does not exist». Молча промолчать значило бы показать несделанное сделанным. */ say(`Ядро отказало (${gone.code ?? "без кода"}): ${gone.reason}`, true); return; } showLinks(gone.sheetHyperlinks); await reread(); say( `Ссылка снята ${EXECUTOR}: намерение «${gone.action}», ячеек изменено ${gone.changedCellCount}. ` + `Поля hyperlink у ячейки ${cell.value.toUpperCase()} больше нет, а ТЕКСТ остался — ` + `«${sheet.getRange(cell.value)?.getValue() ?? ""}».`, ); })();});
ask.addEventListener("click", () => { void (async () => { if (!(await readLinks())) return; say(`Спрошено ${EXECUTOR} намерением read: книга не изменилась ни на ячейку.`); })();});
redraw();void core.f1.loadAsync();
import.meta.hot?.dispose(() => core.destroy());Книга примера: данные, с которыми лист открывается.
import { createTransport, transportExecutorName, transportSettingFromSearch } from "@f1/contract";
/** * КНИГА-ЗАГЛУШКА — ВМЕСТО НАСТОЯЩЕГО ЯДРА. * * ГРАНИЦА ДУБЛЁРА НАЗВАНА, ПОТОМУ ЧТО ОНА ВИДНА НА ЭТОЙ СТРАНИЦЕ. Ссылки * заглушка ведёт по-настоящему (`mock-table-hyperlink.ts`: свой список, * коды отказов ядра, текст ячейки пишется тем же шагом), но ячейки, готовые к * показу (`previewCells`), она в ответе НЕ присылает — живое ядро присылает. * Поэтому пример после правки перечитывает окно: иначе на стенде ячейка * осталась бы пустой, и страница выглядела бы сломанной там, где всё сделано. * Подставлять значение своей рукой было бы хуже — это счёт в TypeScript, * запрещённый инвариантом №1. * * ТРАНСПОРТ ВЫБИРАЕТСЯ НАСТРОЙКОЙ: умолчание — заглушка, * `?core=http://127.0.0.1:5507` уводит на живое ядро. Код примера не меняется. */const setting = transportSettingFromSearch(globalThis.location?.search ?? "", { sheets: ["Лист1"], usedRange: { rowEnd: 2, colEnd: 1 }, cellAt: (row, col) => (row === 0 && col === 0 ? "Документация" : undefined),});
export const transport = createTransport(setting);
/** * КТО ПРАВДА СТАВИТ ССЫЛКУ — и почему это написано рядом с транспортом. Подпись * «сделано ядром» там, где отвечает заглушка, — fake-green в словах (инвариант * №3). Имя исполнителя приходит от САМОГО транспорта и остаётся правдой на живом * ядре. */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-hyperlink", "private": true, "type": "module", "description": "Гиперссылка: ставит и снимает её ЯДРО одной операцией mutate_table_hyperlink_artifacts, а снятие оставляет текст ячейки.", "scripts": { "dev": "vite", "build": "vite build" }, "dependencies": { "@f1/preset-sheets-core": "*", "@f1/sheets": "*", "@f1/contract": "*", "@f1/i18n": "*" }, "devDependencies": { "vite": "^7.0.0" }}- Остальные примеры — Примеры F1.
- Правила, на которые ссылается код, — пять правил.
- Из чего собирается лист — Архитектура.
- Что можно вызвать сегодня — справочник Compute Contract.