Перейти к содержимому
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="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>

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

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

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

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

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

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-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"
}
}

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

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