Двое в одном документе
Две рамки рядом: двигаете выделение в одной — во второй виден чужой курсор с именем; и прямо на экране написано, что видны только окна этого браузера.

В рамке выше работает тот самый проект, чей код лежит ниже, — собранный, но не переписанный: те же файлы, только пропущенные через сборку. Считает в нём не ядро, а дублёр транспорта, поэтому числа в ячейках ничего не значат: смотреть нужно на код.
Страница, которую открывает браузер.
<!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="presence" 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>?core</code> она у каждой своя, и чужая правка сюда не попадёт (полоса присутствия говорит об этом словами); с общим хозяином ядра книга одна на всех, и правка соседа появляется здесь потому, что это ТА ЖЕ правка. </div> <!-- КАНАЛ ВЫБИРАЕТСЯ НАСТРОЙКОЙ, А НЕ ПЕРЕПИСЫВАНИЕМ КОДА: порт один, реализаций три, и продукт ни одной из них не знает. --> <div class="mt-1 text-xs opacity-60"> Канал: <code>?link=memory</code> — обе рамки в одной вкладке (умолчание); <code>?link=broadcast</code> — окна одного браузера, откройте страницу дважды; <code>?link=socket&as=anna</code> — через наш ретранслятор (<code>npm run presence:host</code>), тогда во второй вкладке <code>?link=socket&as=boris</code> — и курсоры видны между вкладками. </div> <!-- КНИГА — ОТДЕЛЬНАЯ НАСТРОЙКА, И ЭТО НЕ ТО ЖЕ, ЧТО КАНАЛ. Канал возит присутствие; книга приезжает от транспорта контракта. --> <div class="mt-1 text-xs opacity-60"> Книга: без <code>?core</code> — своя у каждой рамки (умолчание); <code>?core=http://127.0.0.1:5507</code> — одна на всех через хозяина ядра (<code>npm run core:host</code>, а без собранного ядра <code>npm run core:host -- --stand-in</code>). </div> </header> <div class="flex min-h-0 flex-1 gap-2 p-2"> <section data-presence-frame="anna" class="flex min-h-0 min-w-0 flex-1 flex-col rounded-card border border-base-300" > <div class="border-b border-base-300 px-2 py-1 text-xs font-medium">Окно Анны</div> <div class="seat flex min-h-0 flex-1 flex-col"></div> </section> <section data-presence-frame="boris" class="flex min-h-0 min-w-0 flex-1 flex-col rounded-card border border-base-300" > <div class="border-b border-base-300 px-2 py-1 text-xs font-medium">Окно Бориса</div> <div class="seat flex min-h-0 flex-1 flex-col"></div> </section> </div> </div>
<script type="module" src="./src/main.ts"></script> </body></html>Точка входа: здесь лист создаётся и сюда же приходят события.
import { createSheetsCore, type SheetsCore } from "@f1/preset-sheets-core";import { broadcastLink, memoryLinks, socketLink, type Peer, type PresenceLink } from "@f1/presence";import { book } from "./book.js";import { hostAsks } from "./host.js";import "./styles.css";
/** * ДВОЕ В ОДНОМ ДОКУМЕНТЕ — И НИ ОДНОГО ВЫДУМАННОГО УЧАСТНИКА. * * Две рамки рядом — это ДВА НАСТОЯЩИХ СЕАНСА: у каждой своя транспорт контракта, * своя сборка листа, своя личность и свой конец канала. Они не делят ничего, * кроме канала присутствия, — ровно как два окна браузера. Подвиньте выделение * в левой рамке, и в правой появится курсор Анны с её именем и цветом. * * КАНАЛ ВЫБИРАЕТСЯ НАСТРОЙКОЙ, И ЭТО ГЛАВНОЕ, ЧТО ПОКАЗЫВАЕТ ПРИМЕР. Порт * (`PresenceLink`) один, реализаций три, и продукт ни одной из них не знает: * * · `?link=memory` (умолчание) — обе рамки в одной вкладке. `BroadcastChannel` * не возвращает весть отправителю (и правильно делает: иначе человек видел бы * рядом собственный курсор), а обе рамки здесь на одном его конце — то есть * через него они друг друга не услышали бы вовсе. Витрине канал в памяти * нужен затем, чтобы показать разговор без просьбы «откройте второе окно». * · `?link=broadcast` — окна ОДНОГО браузера. Откройте страницу дважды. * · `?link=socket&relay=ws://127.0.0.1:5508` — СОКЕТ: разные вкладки, разные * браузеры, разные машины. Ретранслятор поднимается `npm run presence:host`. * Вместе с сокетом полезен `?as=anna` — рамка ОДНА, и вкладка становится * одним человеком, а не двумя сразу. * * ЛИЧНОСТЬ ЗДЕСЬ — ОТ «ХОЗЯИНА СТРАНИЦЫ», как и в жизни. Она не выдумана * оболочкой и не хранится ею: два объекта ниже — это то, что в продукте придёт * от учётной записи. Выдуманный в TypeScript пользователь закрыл бы дверь в * совместность навсегда (`docs/READINESS_COLLAB.md`). * * ОДНА КНИГА НА ВСЕХ — ОТДЕЛЬНАЯ НАСТРОЙКА, И ЭТО НЕ ТО ЖЕ САМОЕ, ЧТО КАНАЛ * (расширили столбец — сосед этого не видит). Канал возит ПРИСУТСТВИЕ; книга * приезжает от ТРАНСПОРТА: * * · без `?core` (умолчание) у каждой рамки СВОЯ книга-заглушка. Сосед виден, * курсор ездит, а его правки и ширины столбцов сюда не попадают — потому что * это другая книга. Полоса присутствия говорит это словами, а не молчит; * · `?core=http://127.0.0.1:5507` — ОДИН ХОЗЯИН ЯДРА на всех (`npm run * core:host`, а без собранного ядра — `npm run core:host -- --stand-in`). * Книга буквально одна: правка соседа появляется здесь потому, что это ТА ЖЕ * правка. Сливать нечего — нечему расходиться. * * МЕДЛЕННАЯ ДОРОГА — ТРЕТЬЯ НАСТРОЙКА (`?pace=400`). Она не * про присутствие и не про книгу, а про ЦЕНУ: продукт меряет, во что обходится * перечитывание окна после чужой правки, и выше бюджета отклика произносит на * полосе, что книга работает в одиночку. На заглушке эта цена — доли * миллисекунды, и порога не видно; на живом ядре с книгой владельца то же * чтение стоит 5 953 мс. Задержку объявляет ХОЗЯИН СТРАНИЦЫ, продукт её * ИЗМЕРЯЕТ, и ни одно число на экране не выдумано. * СТРОГИЙ РЕЖИМ — ТРЕТЬЯ НАСТРОЙКА, И ОНА НЕ ЗАВИСИТ ОТ ПЕРВЫХ ДВУХ. * `?mode=strict` — место, где сосед ОТКРЫЛ правку, занято: его ячейка помечена * его цветом пунктиром со словом «правит», а правка туда не начинается. Без * признака (умолчание) замков нет вовсе — как в Excel. Замок объявляется тем же * присутствием, что и курсор, и отпускается сам: правка кончилась, участник * ушёл, связь пропала. * * ПРАВКИ ПО КАНАЛУ НЕ ЕЗДЯТ НИ В ОДНОМ ИЗ ДВУХ СЛУЧАЕВ. По нему едет ИЗВЕСТИЕ * («книга изменилась, перечитай окно»), а содержимое сосед спрашивает у ядра * тем же запросом окна, что и при прокрутке. Согласование расходящихся копий * этим не решается и решаться не должно (`docs/RECON_MERGE.md`). */const { locale, link: kind, relay, only, core, pace, mode } = hostAsks();
const anna: Peer = { id: "anna", name: "Анна Петрова" };const boris: Peer = { id: "boris", name: "Борис Иванов" };
/** * Ключ общего пространства — В ЖИЗНИ ЭТО ДОКУМЕНТ. * * Здесь он один на весь пример и назван явно: подставить его умолчанием внутри * пакета значило бы свести два РАЗНЫХ документа в один разговор. */const space = "витрина-присутствия";
/* ОДИН РАЗГОВОР НА ДВОИХ, если канал в памяти: обе рамки берут его концы. */const room = memoryLinks("this-browser");
const linkFor = (): PresenceLink => { if (kind === "socket") return socketLink({ space, url: relay }); if (kind === "broadcast") return broadcastLink({ space }); return room();};
const seats: SheetsCore[] = [anna, boris] .filter((self) => only === null || self.id === only) .map((self) => createSheetsCore({ mount: document.querySelector<HTMLElement>(`[data-presence-frame="${self.id}"] .seat`)!, /* СВОЯ ТРАНСПОРТ У КАЖДОГО — два сеанса, а не два вида одной книги. А вот КНИГА за ними одна или две, решает хозяин страницы адресом `?core=…`: без него у каждой рамки своя книга-заглушка (и полоса присутствия говорит об этом словами), с ним — один хозяин ядра на всех, и правки соседа видны здесь потому, что это ТЕ ЖЕ правки. */ transport: book(core, pace), locale, /** * КНИГА ВЫШЕ ОКНА ЛИСТА — ИНАЧЕ СТРАНИЦА НЕ МОЖЕТ ПОКАЗАТЬ ТО, ЧТО ОБЕЩАЕТ. * * Здесь стояло 200 строк, и с ними настройка `?pace=` (порог совместности) * была недостижима НИКАКИМ жестом посетителя. Причина измерена * зондом в самом транспорте витрины: первое чтение окна уходит * прямоугольником 517×143, следующие — 987×33, то есть окно ШИРЕ всей * книги. Прокрутка из него не выходит, второго чтения не бывает, а цена * дороги считается медианой минимум ДВУХ проб (`PACE_MIN_SAMPLES`, * @f1/contract) — значит `pace()` честно молчит, и четвёртой честной * строки на полосе нет ни при какой задержке. Страница при этом прямо * зовёт посетителя попробовать `?pace=400`. * * Три тысячи — не «побольше на всякий случай», а втрое над измеренным * окном в 987 строк. Сузится окно (полоса `fix/viewport-overask`) — станет * только вернее; вырастет вдвое — витрина всё ещё работает. */ rows: 3000, cols: 26, /* СТРОГИЙ РЕЖИМ — НАСТРОЙКА ХОЗЯИНА СТРАНИЦЫ (`?mode=strict`), а не устройство листа: по умолчанию его нет, как и в Excel. Со строгим режимом ячейка, где сосед открыл правку, помечена его цветом и именем, и правка туда не начинается — а полоса присутствия говорит это словами. Без него всё как раньше: пишут все, последняя правка побеждает, и проигравшего называют. */ presence: { link: linkFor(), self, mode }, }), );
/* Рамка без сеанса на странице не остаётся: пустая рамка с чужим именем выглядит как «человек здесь, но ничего не делает» — то есть врёт. */if (only !== null) { for (const peer of [anna, boris]) { if (peer.id === only) continue; document.querySelector(`[data-presence-frame="${peer.id}"]`)?.remove(); }}
import.meta.hot?.dispose(() => seats.forEach((seat) => seat.destroy()));Книга примера: данные, с которыми лист открывается.
import { createMockTransport, createServiceHostTransport, type ContractTransport } from "@f1/contract";
/** * КНИГА-ЗАГЛУШКА — ВМЕСТО НАСТОЯЩЕГО ЯДРА. * * Транспорт контракта — единственное место, где лист разговаривает с ядром * (инвариант №1). В продукте здесь стоит `wasm` или мост хоста. * * КАЖДАЯ РАМКА ЗОВЁТ ЭТУ ФУНКЦИЮ СВОИМ ВЫЗОВОМ, и это часть честности примера: * два сеанса не делят ни транспорт, ни книгу, ни состояние — они делят только * КАНАЛ ПРИСУТСТВИЯ. Один транспорт на двоих незаметно превратил бы пример в * «одна книга, два вида», а это совсем другое утверждение. * * ПРАВКИ ДРУГ ДРУГА ОНИ ПОЭТОМУ НЕ ВИДЯТ — и не должны: пример показывает * ПРИСУТСТВИЕ, и ровно это на нём и написано. Это и есть тот случай, который * со стороны выглядит ошибкой: сосед виден, а расширенный им столбец — нет. * * А ВОТ КАК ЭТО ЧИНИТСЯ — И ЧИНИТСЯ НЕ ЗДЕСЬ. Второй вызов ниже даёт ту же * страницу, разговаривающую с ОДНИМ ХОЗЯИНОМ ЯДРА (`?core=…`, поднимается * `npm run core:host`). Тогда книга у всех буквально одна: ширина столбца у * соседа появится потому, что это ТА ЖЕ ширина. Никакого «слияния» при этом не * появляется и появиться не может — сливать нечего. */export const book = (core: string | null = null, pace = 0): ContractTransport => pace > 0 ? slowRoad(bookOnFastRoad(core), pace) : bookOnFastRoad(core);
const bookOnFastRoad = (core: string | null): ContractTransport => core === null ? createMockTransport({ cellAt: (row, col) => (col === 0 ? `строка ${row + 1}` : `${(row + 1) * (col + 1)}`), }) : /* КНИГА ЖИВЁТ У ХОЗЯИНА, а не в рамке: каждая рамка получает СВОЮ транспорт к ОДНОМУ адресу — ровно так же, как две вкладки на двух машинах. */ createServiceHostTransport({ endpoint: core });
/** * ДОРОГА ДО КНИГИ, КОТОРАЯ ОТВЕЧАЕТ НЕ БЫСТРЕЕ, ЧЕМ ЗА `ms`. * * ЭТО СВОЙСТВО ДОРОГИ, А НЕ ПРИТВОРСТВО ПРОДУКТА. Порог совместности продукт * меряет сам (`pacedTransport` в `@f1/contract`) — по той дороге, которая ему * досталась. На заглушке эта дорога стоит доли миллисекунды, поэтому НА * ВИТРИНЕ порога не увидеть вовсе: показывать нечего. А на живом ядре с книгой * владельца то же чтение окна стоит 5 953 мс (замер * `tools/bench-collab-threshold.mjs`), и продукт обязан это ПРОИЗНЕСТИ. * * Медленная дорога и есть тот случай, только достижимый без 33 мегабайт и * собранного ядра: хозяин страницы объявляет задержку (`?pace=400`), продукт * её ИЗМЕРЯЕТ и говорит правду о том, что измерил. Ни одно число на экране при * этом не выдумано — выдумана только медленная дорога, и выдумана она ЗДЕСЬ, * на стороне хозяина страницы, а не в продукте. */const slowRoad = (inner: ContractTransport, ms: number): ContractTransport => ({ send: async (request) => { await new Promise((resolve) => setTimeout(resolve, ms)); return inner.send(request); }, /* Общность документа и вторая сторона разговора — свойства ДОРОГИ, и задержка их не меняет: полоса рядом говорит о них своими строками. */ share: inner.share, /* ИМЯ КНИГИ — ТОЖЕ СВОЙСТВО ДОРОГИ, И ОНО ПРОБРАСЫВАЕТСЯ ГЕТТЕРОМ, а не копией: у транспорта оно ПОЯВЛЯЕТСЯ и МЕНЯЕТСЯ, когда книгу открывают (`subsetId` из `@f1/contract`). Обёртка, потерявшая его, ломает ровно сочетание `?core=…&pace=…`: живое ядро отвечает на запрос без имени книги отказом, то есть медленная дорога превращала бы витрину в пустой лист. На заглушке это незаметно — она имени не требует, и потому дефект здесь молчит ровно до первого живого ядра. */ get subsetId() { return inner.subsetId; }, ...(inner.subscribe ? { subscribe: inner.subscribe.bind(inner) } : {}),});Сторона хозяина страницы: что приложение отвечает листу и что от него получает.
import { applyLocaleAttributes, resolveLocale, setLocale, type Locale } from "@f1/i18n";import { DEFAULT_CORE_HOST_ENDPOINT } from "@f1/contract";import { CO_EDIT_DEFAULT, CO_EDIT_MODES, DEFAULT_PRESENCE_ENDPOINT, type CoEditMode } from "@f1/presence";
/** * ЧТО ХОЗЯИН СТРАНИЦЫ ДАЁТ ЛИСТУ — тема, язык и КАНАЛ ПРИСУТСТВИЯ. * * У встраивающего это уже есть своё: тему он знает из настроек, язык — из * профиля пользователя, а канал выбирает потому, что у него и документ, и * учётная запись, и служба (если она есть). Здесь всё читается из адреса * (`?theme=dark&locale=ar&link=socket`) ровно затем, чтобы на любое состояние * можно было дать ссылку и чтобы его проверял обход: настройка, доступная * только щелчком, недоступна ни ссылке, ни автоматической проверке. * * ПРИЗНАКИ СТАВЯТСЯ НА КОРЕНЬ ДОКУМЕНТА — потому что это делает ХОЗЯИН, а не * лист. Лист читает тему оттуда, где стоит (`:host-context`), а язык получает * СВОЙСТВОМ сборки: два листа на одной странице вправе говорить на разном. * * НАПРАВЛЕНИЕ ПИСЬМА — ИНТЕРФЕЙСА, А НЕ ДОКУМЕНТА. `dir="rtl"` зеркалит хром * страницы; «Лист справа налево» из Excel — свойство КНИГИ, и сетка от * арабского языка интерфейса не переворачивается. Это не пропуск, а решение * (`locale.ts`). */
export type LinkKind = "memory" | "broadcast" | "socket";
const KINDS: readonly LinkKind[] = ["memory", "broadcast", "socket"];
export interface HostAsks { readonly theme: "light" | "dark"; readonly locale: Locale; readonly link: LinkKind; readonly relay: string; /** * Кем открыта эта вкладка: `anna`, `boris` или `null` — обеими сразу. * * Нужно ровно для сокета: две вкладки, в каждой обе рамки, дали бы двух * участников с ОДНИМ идентификатором на каждого — ретранслятор честно * раздал бы вести, а список показал бы человеку его самого. */ readonly only: "anna" | "boris" | null; /** * АДРЕС ХОЗЯИНА ЯДРА — ИМ И ОБЪЯВЛЯЕТСЯ ОБЩНОСТЬ КНИГИ. * * `null` — хозяин не назван, и тогда у каждой рамки СВОЯ книга-заглушка. Это * тот случай, который со стороны выглядит ошибкой: сосед виден, а его ширина * столбца — нет. * * ПОЧЕМУ ОБЩНОСТЬ ОБЪЯВЛЯЕТСЯ АДРЕСОМ, А НЕ КЛЮЧОМ, КАК У ПРИСУТСТВИЯ. У * присутствия ключ нужен потому, что ретранслятор один, а документов у него * много: комнату надо назвать. У хозяина ядра наоборот — он держит ОДИН живой * сеанс, то есть одну книгу, и адрес хозяина и есть имя этой книги. Второй * ключ поверх адреса был бы ложью: назвав разные ключи одному хозяину, люди * всё равно правили бы одну книгу. Появится хозяин с несколькими книгами — * появится ключ, и придёт он оттуда же, откуда адрес: ОТ ХОЗЯИНА СТРАНИЦЫ. */ readonly core: string | null; /** * СКОЛЬКО ДЕРЖИТ ДОРОГА ДО КНИГИ, мс (порог совместности * говорится на экране). * * Ноль — дорога как есть. Больше нуля — МЕДЛЕННАЯ дорога: ровно то, что * случается на живом ядре с большой книгой (замер: чтение окна на книге * владельца 5 953 мс), только достижимое без 33 мегабайт и собранного ядра. * Продукт задержку не изображает, а ИЗМЕРЯЕТ — и произносит то, что измерил. */ readonly pace: number; /** * РЕЖИМ СОВМЕСТНОЙ ПРАВКИ — `?mode=strict`. * * Читается из адреса по той же причине, что тема и язык: настройка, доступная * только щелчком, недостижима ни ссылке, ни обходу, а строгий режим обязан * проверяться ДВУМЯ изолированными вкладками — то есть двумя адресами. * * Умолчание берётся у модели (`CO_EDIT_DEFAULT`), а не пишется здесь: второй * владелец умолчания означал бы, что витрина показывает не тот режим, который * у продукта по умолчанию. */ readonly mode: CoEditMode;}
export function hostAsks(): HostAsks { const params = new URLSearchParams(location.search); const theme = params.get("theme") === "dark" ? "dark" : "light"; const locale = resolveLocale(params.get("locale") ?? undefined, "ru"); const asked = params.get("link"); /* Неизвестное слово молча не превращается в сокет: тихая подстановка канала означала бы, что подпись на экране говорит не о том канале, который есть. */ const link = KINDS.find((kind) => kind === asked) ?? "memory"; const only = params.get("as"); /* `?core` без значения означает «возьми умолчание», а отсутствие признака — «хозяина нет вовсе». Разница видна на экране: во втором случае продукт говорит, что документ у каждого свой. */ const askedCore = params.get("core"); /* Отрицательное и нечисловое молча не превращается в задержку: дорога, о которой сказано неразборчиво, остаётся такой, какая есть. */ const askedPace = Number(params.get("pace")); /* Неизвестное слово молча не превращается в строгий режим — тем же доводом, что и у канала: подпись на экране говорила бы не о том режиме, который есть, а обещание защиты дороже обещания канала. */ const askedMode = params.get("mode");
document.documentElement.setAttribute("data-theme", theme); /* Язык — один на процесс: словарь модульный, и ставит его хозяин страницы. */ setLocale(locale); applyLocaleAttributes(document.documentElement, locale);
return { theme, locale, link, relay: params.get("relay") ?? DEFAULT_PRESENCE_ENDPOINT, only: only === "anna" || only === "boris" ? only : null, core: askedCore === null ? null : askedCore === "" ? DEFAULT_CORE_HOST_ENDPOINT : askedCore, pace: Number.isFinite(askedPace) && askedPace > 0 ? askedPace : 0, mode: CO_EDIT_MODES.find((kind) => kind === askedMode) ?? CO_EDIT_DEFAULT, };}Оформление страницы вокруг листа.
/* ЛИСТ СТИЛЕЙ НАБОРА КОМПОНЕНТОВ — ЕДИНСТВЕННАЯ СТРОКА, КОТОРУЮ НЕЛЬЗЯ ЗАБЫТЬ. Собственное оформление лист приносит в теневые корни сам, но утилиты раскладки, которыми он держит свои органы, компилируются ЭТИМ листом. Без него страница не просто выглядит иначе — вкладка ЗАВИСАЕТ (бесконечный цикл замера у сетки без размеров). У потребителя вне репозитория это `@fastoffices/formula-kit` из его node_modules. */@import "@fastoffices/formula-kit"; /* см. примечание выше */html,body { height: 100%; margin: 0;}Из каких пакетов собран пример.
{ "name": "f1-example-presence", "private": true, "type": "module", "description": "Двое в одном документе: чужой курсор с именем в реальном времени — и честная подпись о том, кого мы на самом деле видим.", "scripts": { "dev": "vite", "build": "vite build" }, "dependencies": { "@f1/preset-sheets-core": "*", "@f1/presence": "*", "@f1/contract": "*", "@f1/i18n": "*" }, "devDependencies": { "vite": "^7.0.0" }}- Остальные примеры — Примеры F1.
- Правила, на которые ссылается код, — пять правил.
- Из чего собирается лист — Архитектура.
- Что можно вызвать сегодня — справочник Compute Contract.