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

Живой пример: редактор в браузере

Работающий редактор A4 на странице документации и весь маленький проект, которым он подключается: страница, код рамки и хост.

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

Редактор A4 с открытым договором
открыть отдельно ↗Настоящий маленький проект: рамка выше запускает именно те файлы, что показаны ниже, из examples/a4-embed

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

Смотреть его стоит в таком порядке: сначала страница, потом подключение, потом сервер. Первые два файла отвечают на вопрос «где появляется редактор», третий — на вопрос «откуда документ и куда уходят правки».

Страница вашего приложения. Редактора в ней нет — есть карточка документа и место, куда он встанет.

index.html
<!doctype html>
<!--
Страница вашего приложения. Редактора здесь нет: есть карточка документа,
какая у вас уже наверняка есть, и место, куда он встанет.
-->
<html lang="ru">
<head>
<meta charset="utf-8" />
<title>Договор №42 — документы</title>
<link rel="stylesheet" href="/src/styles.css" />
</head>
<body>
<header class="bar">
<span class="brand">Документы</span>
<span class="who" data-who>…</span>
</header>
<main class="card">
<h1>Договор №42</h1>
<p class="meta">docx · загружен 14 марта · <span data-state>только просмотр</span></p>
<button type="button" data-edit>Редактировать</button>
<!-- Сюда встанет рамка редактора. До нажатия её нет: пока документ
не открывают, поднимать кабинку незачем. -->
<div class="stage" data-stage hidden></div>
</main>
<script type="module" src="/src/embed.js"></script>
</body>
</html>

Всё подключение целиком. Рамка на адрес вашего домена; никакого модуля в браузер не загружается.

src/embed.js
/**
* Подключение редактора — весь клиентский код примера.
*
* Редактор открывается рамкой на адрес `/fastdoc/?doc=<идентификатор>`.
* Никакого модуля в браузер не загружается: документ считает ядро на
* сервере, а рамка показывает его страницу.
*
* Событий наружу редактор не шлёт, и слушать здесь нечего. Связь с вашим
* приложением идёт другой дорогой: правки редактор отправляет на ваш же
* сервер, в «файл по идентификатору». Что произошло с документом, вы
* узнаёте оттуда, а не из браузера.
*/
const DOC_ID = "contract-42";
const stage = document.querySelector("[data-stage]");
const edit = document.querySelector("[data-edit]");
const state = document.querySelector("[data-state]");
const who = document.querySelector("[data-who]");
/* Имя пользователя берём из своего же API — того самого, которое потом
спросит редактор. Одна личность на всё приложение. */
fetch("/v1/me")
.then((res) => (res.ok ? res.json() : null))
.then((user) => {
who.textContent = user ? user.name : "не выполнен вход";
})
.catch(() => {
who.textContent = "не выполнен вход";
});
edit.addEventListener("click", () => {
const frame = document.createElement("iframe");
frame.src = `/fastdoc/?doc=${encodeURIComponent(DOC_ID)}`;
frame.title = "Редактор документа";
/* Буфер обмена нужен явным разрешением: без него копирование между
документом и остальным браузером работать не будет. */
frame.allow = "clipboard-read; clipboard-write";
stage.replaceChildren(frame);
stage.hidden = false;
edit.disabled = true;
state.textContent = "открыт на правку";
});

Оформление вашей страницы. Редактор его не касается: внутри рамки свой документ, снаружи ваш интерфейс.

src/styles.css
/* Оформление вашей страницы. Редактор его не касается: внутри рамки свой
документ, снаружи — ваш интерфейс. */
:root {
color-scheme: light;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}
body {
margin: 0;
background: #f6f7f9;
color: #1a1d21;
}
.bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.25rem;
background: #fff;
border-bottom: 1px solid #e3e6ea;
}
.brand {
font-weight: 600;
}
.who {
color: #6b7280;
font-size: 0.875rem;
}
.card {
max-width: 64rem;
margin: 1.5rem auto;
padding: 1.25rem;
background: #fff;
border: 1px solid #e3e6ea;
border-radius: 0.5rem;
}
h1 {
margin: 0 0 0.25rem;
font-size: 1.25rem;
}
.meta {
margin: 0 0 1rem;
color: #6b7280;
font-size: 0.875rem;
}
button {
padding: 0.5rem 1rem;
border: 0;
border-radius: 0.375rem;
background: #2563eb;
color: #fff;
font: inherit;
cursor: pointer;
}
button[disabled] {
background: #9aa4b2;
cursor: default;
}
/* Рамке нужна заданная высота: редактор занимает её целиком, а сам её не
назначает — снаружи виднее, сколько места ему отдать. */
.stage {
margin-top: 1rem;
height: 75vh;
border: 1px solid #e3e6ea;
border-radius: 0.375rem;
overflow: hidden;
}
.stage iframe {
display: block;
width: 100%;
height: 100%;
border: 0;
}

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

server/host.mjs
/**
* Ваш сервер: два адреса, которых редактору достаточно, и приём правок.
*
* Редактор своих учётных записей не имеет и своего хранилища тоже. Кто
* пришёл и какой файл ему отдать — решаете вы, этими двумя ответами:
*
* GET /v1/me — кто пришёл
* GET /v1/files/{id}/content — сам документ, если этому человеку можно
* POST /v1/files/{id}/replace — сохранение правок обратно к вам
*
* Если у вас есть личный кабинет и файлы, оба первых адреса уже написаны.
*
* Здесь же для полноты картины лежит и то, что в вашем хозяйстве обычно
* делает nginx: раздача страницы и проброс `/fastdoc/` на веб-хост A4.
* Отдельным сервером это или правилом в вашем — дело вкуса; важно, что
* редактор и ваш API отвечают на одном домене и с теми же куками.
*/
import { createServer } from "node:http";
import { readFile, writeFile } from "node:fs/promises";
import { extname, join, normalize } from "node:path";
const arg = (name, fallback) => {
const at = process.argv.indexOf(name);
return at > 0 && process.argv[at + 1] ? process.argv[at + 1] : fallback;
};
const docPath = arg("--doc", "./document.docx");
const port = Number(arg("--port", "8080"));
/* Адрес веб-хоста A4 — того самого процесса, который поднимает кабинки. */
const webhost = arg("--webhost", "http://127.0.0.1:8090");
const root = new URL("../", import.meta.url).pathname;
const TYPES = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
};
const DOCX =
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
/**
* Правки приходят как `multipart/form-data` — одной частью с именем `file`.
*
* Тело обязательно РАЗБИРАТЬ. Если записать в документ сырое тело запроса,
* к началу файла приклеятся служебные строки формы, и .docx перестанет
* открываться — молча, без ошибки в журнале. Признак этой беды при
* распаковке: «extra bytes at beginning».
*/
const filePart = (body, contentType) => {
const boundary = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType ?? "");
if (!boundary) return null;
const mark = Buffer.from(`--${boundary[1] ?? boundary[2]}`);
let at = body.indexOf(mark);
while (at !== -1) {
const headEnd = body.indexOf("\r\n\r\n", at);
if (headEnd === -1) return null;
const head = body.subarray(at, headEnd).toString("utf8");
const next = body.indexOf(mark, headEnd);
if (next === -1) return null;
/* Перед следующей границей стоит CRLF, который к содержимому не
относится: он разделитель, а не байт документа. */
if (/name="file"/i.test(head)) return body.subarray(headEnd + 4, next - 2);
at = next;
}
return null;
};
const readBody = (req) =>
new Promise((resolve, reject) => {
const chunks = [];
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", () => resolve(Buffer.concat(chunks)));
req.on("error", reject);
});
const server = createServer(async (req, res) => {
const url = new URL(req.url, `http://127.0.0.1:${port}`);
/* Кто пришёл. В настоящем приложении здесь ваша сессия; отказ отвечайте
кодом 401 или 403 — редактор покажет его человеку словами. */
if (url.pathname === "/v1/me") {
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ id: "u-1", name: "Анна Петрова" }));
return;
}
/* Файл по идентификатору. Право проверяете вы: не отдали файл — редактор
не откроется, и это правильный ответ. */
if (url.pathname.endsWith("/content") && req.method === "GET") {
res.writeHead(200, {
"content-type": DOCX,
/* Имя файла отсюда попадёт в заголовок вкладки и в имя копии, если
документ понадобится второму человеку. */
"content-disposition":
"attachment; filename*=UTF-8''" + encodeURIComponent("Договор №42.docx"),
});
res.end(await readFile(docPath));
return;
}
/* Правки обратно. Здесь — ваше хранилище: версия, аудит, уведомления. */
if (url.pathname.endsWith("/replace") && req.method === "POST") {
const body = await readBody(req);
const bytes = filePart(body, req.headers["content-type"]);
if (!bytes) {
res.writeHead(400, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "ожидалась часть с именем file" }));
return;
}
await writeFile(docPath, bytes);
console.log(`сохранено ${bytes.length} байт`);
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true }));
return;
}
/* Проброс на веб-хост A4. Куки идут насквозь: по ним он и спросит у вас,
кто пришёл. В рабочем хозяйстве это правило nginx. */
if (url.pathname === "/fastdoc/" || url.pathname.startsWith("/fastdoc/")) {
const target = new URL(url.pathname + url.search, webhost);
const upstream = await fetch(target, {
method: req.method,
headers: { ...req.headers, host: new URL(webhost).host },
body: req.method === "GET" || req.method === "HEAD" ? undefined : req,
redirect: "manual",
duplex: "half",
}).catch(() => null);
if (!upstream) {
res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
res.end("веб-хост A4 не отвечает");
return;
}
res.writeHead(upstream.status, Object.fromEntries(upstream.headers));
res.end(Buffer.from(await upstream.arrayBuffer()));
return;
}
/* Ваша страница и её файлы. */
const name = url.pathname === "/" ? "/index.html" : url.pathname;
const path = join(root, normalize(name).replace(/^(\.\.[/\\])+/, ""));
const body = await readFile(path).catch(() => null);
if (!body) {
res.writeHead(404);
res.end();
return;
}
res.writeHead(200, { "content-type": TYPES[extname(path)] ?? "application/octet-stream" });
res.end(body);
});
server.listen(port, () => {
console.log(`пример открыт: http://127.0.0.1:${port}/`);
console.log(`редактор берётся с ${webhost}`);
});

Правки приходят к вам как multipart/form-data — одной частью с именем file. Тело обязательно разбирать. Если записать в документ сырое тело запроса, к началу файла приклеятся служебные строки формы, и документ перестанет открываться — молча, без ошибки в журнале. Признак этой беды при распаковке: «extra bytes at beginning».

На этом попадались и мы сами, поэтому разбор в примере выписан целиком, а не сведён к вызову библиотеки.

Чем этот пример отличается от рабочего встраивания

Заголовок раздела «Чем этот пример отличается от рабочего встраивания»
Что В этом примере В вашем приложении
Кто пользователь Гость: личность одна на всех посетителей Ваш вошедший пользователь
Откуда документ Один и тот же демонстрационный файл Ваш файл по идентификатору
Куда идут правки Никуда: сохранение принимается и не записывается В ваше хранилище, вашим же API
Права Не проверяются Проверяете вы, в том числе повторно во время работы
Проброс /fastdoc/ Сделан тем же процессом, для краткости Обычно правило nginx

Всё остальное совпадает: тот же редактор, то же ядро, тот же способ встраивания.

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

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