lemon_m3 часа назадКраткое содержание статьи
Quill в Next.js App Router, editor, autosave и HTML viewer с подсветкой кода
Время на прочтение10 минОхват и читатели2.8KВеб-разработка*TypeScript*ReactJS*ТуториалWYSIWYG-редактор в Next.js App Router затрагивает несколько слоев приложения. Quill работает с DOM и browser API, поэтому сам редактор живет на клиенте. Контент нужно хранить отдельно от экземпляра Quill, передавать в компонент при смене сущности, сохранять на сервер и затем показывать без toolbar и режима редактирования.Если хранить результат Quill как HTML, редактор и viewer могут работать с одной строкой contentHtml. Editor меняет HTML через onChange. Autosave отправляет новую версию после паузы во вводе. Viewer получает сохраненный HTML и отдельно обрабатывает элементы, которые Quill использует для code blocks. Для подсветки кода добавляется highlight.js.
Client-only редактор
use client сам по себе не означает, что библиотека спокойно работает во время серверной подготовки страницы. Quill использует DOM, window и собственные browser-зависимости. Для него можно оставить отдельный Client Component и загрузить сам react-quill-new через dynamic с ssr: false.
Quill Syntax также работает с highlight.js. В этой конфигурации модуль ожидает hljs в window.
// src/components/editor/NoteEditorClient.tsx "use client";
import dynamic from "next/dynamic"; import hljs from "highlight.js";
if (typeof window !== "undefined") { window.hljs = hljs; }
const ReactQuill = dynamic(
async () => (await import("react-quill-new")).default,
{
ssr: false,
}
);Next.js не рендерит ReactQuill на сервере. Импорт библиотеки выполняется в браузере, где уже доступны DOM и window.Проверка typeof window !== "undefined" относится только к глобальному hljs. Сам Quill отделен от SSR через dynamic.
CSS Quill и highlight.js
Quill Snow использует собственные классы для toolbar, editor area, списков, заголовков и code blocks. Их стили можно подключить один раз на уровне приложения. Тема highlight.js также подключается глобально, если viewer и editor используют одну схему подсветки.
// src/app/layout.tsx
import "react-quill-new/dist/quill.snow.css";
import "highlight.js/styles/github-dark.css";После этого компоненты Quill получают базовые стили .ql-toolbar, .ql-container и .ql-editor. Viewer позже сможет использовать часть тех же классов без создания второго набора правил для обычного текста.
Toolbar и history
WYSIWYG-редактор обычно требует больше, чем bold и italic. В конфигурации ниже есть заголовки, шрифт, цвет, списки, blockquote, inline code, code block, ссылки, выравнивание и очистка форматирования.Undo и redo подключены к history Quill. userOnly: true оставляет в history пользовательские изменения.
const modules = { syntax: true,
toolbar: {
container: [
["undo", "redo"],
[{ header: [1, 2, 3, false] }, { font: [] }],
["bold", "italic", "underline", "strike", "code"],
[{ script: "sub" }, { script: "super" }],
[{ color: [] }, { background: [] }],
["blockquote", "code-block"],
[
{ list: "ordered" },
{ list: "bullet" },
{ indent: "-1" },
{ indent: "+1" },
],
[{ align: [] }],
["link"],
["clean"],
],
handlers: { undo: function () { this.quill.history.undo(); },
redo: function () { this.quill.history.redo(); }, }, },
history: { delay: 400, maxStack: 80, userOnly: true, },
clipboard: {
matchVisual: false,
},
} as const;syntax: true включает поддержку code blocks. Toolbar передает команды undo и redo во внутренний history Quill.Конфигурацию можно держать вне компонента. При каждом render React не создает новый объект modules.
Controlled editor
Компонент editor получает HTML снаружи и возвращает следующую версию через onChange. Quill не знает, где хранится заметка и каким способом контент попадет на сервер.
export default function NoteEditorClient({
value,
onChange,
}: {
value: string;
onChange: (nextHtml: string) => void;
}) {
const handleChange = useCallback(
(content: string) => {
onChange(content);
},
[onChange]
);
return (
<div className="wb-editor">
<ReactQuill
theme="snow"
value={value}
onChange={handleChange}
modules={modules}
/>
</div>
);
}value содержит текущий HTML. onChange получает HTML после изменения документа и передает его в слой состояния приложения.Такой компонент можно подключить к локальному store, form state или другому источнику данных без изменений внутри editor.
Смена редактируемой сущности
Проблема появляется при повторном использовании одного editor для разных заметок. React сохраняет экземпляр компонента между render, если его позиция в дереве не изменилась. Quill при этом хранит собственное внутреннее состояние, selection и history.
Выбранная заметка в данном случае задается параметром note в URL.
const sp = useSearchParams(); const noteId = sp.get("note") ?? "";
const { getNote, updateNoteContent, getNoteSaveInfo, } = useWorkbenchStore();
const note = noteId
? getNote(projectId, noteId)
: undefined;При смене noteId из store выбирается другая сущность с другим contentHtml.
Editor рендерится с key={note.id}.
<NoteEditorClient
key={note.id}
value={note.contentHtml}
onChange={(nextHtml) =>
updateNoteContent(note.id, nextHtml)
}
/>Новый key заставляет React размонтировать старый editor и создать новый. Selection, history и внутреннее состояние предыдущей заметки не переходят в следующую.Одного обновления value для WYSIWYG-компонента иногда недостаточно, если библиотека хранит состояние вне React props. Remount по id убирает этот слой между двумя сущностями.
HTML в состоянии приложения
Quill отдает HTML при каждом изменении. Текст можно записывать в локальный store без ожидания сервера, чтобы controlled value соответствовал текущему содержимому editor.
После локального обновления вызывается autosave.
const updateNoteContent = useCallback(
(noteId: Id, nextHtml: string) => {
setDb((prev) => ({
...prev,
notes: prev.notes.map((note) =>
note.id === noteId
? { ...note, contentHtml: nextHtml }
: note
),
}));
scheduleSaveNote(noteId, nextHtml);
},
[scheduleSaveNote]
);contentHtml меняется в store на каждом onChange. Компонент получает новое значение из того же store.Серверная запись идет отдельно. Editor не блокируется на время каждого запроса.
Autosave
Autosave не должен отправлять server action после каждого символа. Для каждой заметки хранится собственный timer. Новое изменение отменяет еще не запущенный timer предыдущей версии.
const saveTimersRef = React.useRef<Map<Id, number>>(new Map());
const scheduleSaveNote = useCallback(
(noteId: Id, nextHtml: string) => {
const currentTimer =
saveTimersRef.current.get(noteId);
if (currentTimer) { window.clearTimeout(currentTimer); }
setNoteSaveInfoById((prev) => ({
...prev,
[noteId]: {
status: "saving",
error: null,
},
}));
const timer = window.setTimeout(() => { const updatedAt = new Date().toISOString();
void updateNoteContentAction({ noteId, contentHtml: nextHtml, updatedAt, });
}, 650);
saveTimersRef.current.set(noteId, timer);
},
[refreshFromServer]
);Каждая новая версия HTML перезапускает задержку 650 мс. Запрос уходит после паузы во вводе.Map разделяет таймеры разных заметок. Переключение между двумя сущностями не заставляет их использовать один общий debounce timer.
Server Action
Autosave передает noteId, HTML и updatedAt в Server Action. Сервер повторно проверяет payload через Zod, находит заметку и меняет сохраненную версию.
export async function updateNoteContentAction(
payload: unknown
): Promise<Result> {
const parsed =
UpdateNoteContentPayloadSchema.safeParse(payload);
if (!parsed.success) {
return {
ok: false,
error: parsed.error.message,
};
}
const { noteId, contentHtml, updatedAt, } = parsed.data;
let projectId: string | null = null;
const res = await mutateDb((db) => {
const note =
db.notes.find((x) => x.id === noteId);
if (!note) return;
projectId = note.projectId; note.contentHtml = contentHtml; note.updatedAt = updatedAt; });
if (res.ok && projectId) { revalidatePath(`/p/${projectId}`); }
return res;
}Editor не вызывает API route через fetch. Запись проходит через Server Action.После мутации revalidatePath инвалидирует серверное представление маршрута проекта.
Saving, saved и error
Локальный HTML и серверный статус сохранения лучше не смешивать в одном поле. Пользователь уже может видеть новую версию текста, пока action еще выполняется.
После ответа store отдельно меняет updatedAt и save state.
void updateNoteContentAction({
noteId,
contentHtml: nextHtml,
updatedAt,
})
.then((result) => {
if (!result.ok) {
setNoteSaveInfoById((prev) => ({
...prev,
[noteId]: {
status: "error",
error: result.error,
},
}));
void refreshFromServer(); return; }
setDb((prev) => ({
...prev,
notes: prev.notes.map((note) =>
note.id === noteId
? { ...note, updatedAt }
: note
),
}));
setNoteSaveInfoById((prev) => ({
...prev,
[noteId]: {
status: "saved",
lastSavedAt: updatedAt,
error: null,
},
}));
})
.catch(() => {
setNoteSaveInfoById((prev) => ({
...prev,
[noteId]: {
status: "error",
error: "Network error",
},
}));
void refreshFromServer();
});updatedAt меняется после ok. Ошибка записи переводит заметку в error и запускает перечитывание серверного состояния.
Статус можно вывести рядом с заголовком заметки.
<div className="wb-tree-meta"> обновлено:{" "} {new Date(note.updatedAt).toLocaleString("ru-RU")}
{saveInfo.status === "saving" && " • сохранение…"}
{saveInfo.status === "saved" && " • сохранено"}
{saveInfo.status === "error" &&
" • ошибка сохранения"}
</div>Редактор продолжает показывать локальный contentHtml. Строка статуса отдельно сообщает состояние server action.
Editor и viewer
Режим чтения не требует второго формата данных. Можно сохранить HTML Quill и использовать его и для editor, и для viewer.В workspace хранится только текущий режим.
const [mode, setMode] = useState<"edit" | "view">("edit");
<Button
variant="ghost"
onClick={() =>
setMode((mode) =>
mode === "edit" ? "view" : "edit"
)
}
>
{mode === "edit"
? "Просмотр"
: "Редактор"}
</Button>В режиме edit работает ReactQuill. В режиме view тот же contentHtml проходит предварительную обработку.
const previewHtml = useMemo(() => { if (!note) return "";
return prepareHtmlForPreview(
note.contentHtml || ""
);
}, [note]);Отдельная функция нужна в основном из-за code blocks и служебной Quill-разметки. Обычные параграфы, заголовки и списки остаются HTML.
Viewer с Quill-стилями
Quill уже содержит CSS для .ql-editor. Viewer может использовать этот класс без toolbar и editable container.
<div
key={note.id}
className="wb-viewer ql-snow"
>
<div
className="ql-editor"
dangerouslySetInnerHTML={{
__html: previewHtml,
}}
/>
</div>Класс ql-snow оставляет контекст темы. .ql-editor применяет типографику Quill к сохраненному HTML.dangerouslySetInnerHTML вставляет строку как HTML. Если такой контент может поступать от недоверенного источника, перед выводом нужен отдельный этап sanitization. В текущем контуре prepareHtmlForPreview преобразует разметку, но не является sanitizer.
Разметка code block
Формат code blocks зависит от версии и конфигурации Quill. В сохраненном HTML могут встретиться контейнеры .ql-code-block-container, отдельные .ql-code-block и pre.ql-syntax.Viewer приводит эти варианты к обычной структуре pre > code, с которой работает highlight.js.
Для старого контейнера несколько строк собираются в один блок.
doc
.querySelectorAll(".ql-code-block-container")
.forEach((container) => {
const blocks = Array.from(
container.querySelectorAll(
".ql-code-block"
)
);
const lines = blocks .map((block) => block.textContent || "") .join("\n");
if (!lines.trim()) return;
const pre = doc.createElement("pre");
highlightIntoPre(doc, pre, lines);
container.parentNode?.replaceChild(
pre,
container
);
});Каждый .ql-code-block содержит одну строку. Viewer объединяет строки через \n и заменяет служебный контейнер на pre.HTML заметки при этом не меняется в store. Преобразование выполняется только для режима просмотра.
Новый формат code block
Другой вариант Quill уже содержит pre. Viewer не создает новый контейнер, а подсвечивает существующий.
doc
.querySelectorAll(
"pre.ql-syntax, pre[data-language]"
)
.forEach((preEl) => {
const el = preEl as HTMLElement;
if (el.classList.contains("hljs")) { return; }
const text = el.textContent || "";
if (!text.trim()) return;
highlightIntoPre(doc, el, text);
});Проверка hljs не запускает повторную обработку уже подготовленного элемента.
Отдельная ветка обрабатывает одиночный .ql-code-block, если он находится вне контейнера.
doc
.querySelectorAll(".ql-code-block")
.forEach((block) => {
if (
block.closest(
".ql-code-block-container"
)
) {
return;
}
const text = block.textContent || "";
if (!text.trim()) return;
const pre = doc.createElement("pre");
highlightIntoPre(doc, pre, text);
block.parentNode?.replaceChild(
pre,
block
);
});Viewer принимает несколько вариантов HTML и приводит их к одной структуре перед render.
DOMParser
Подготовка viewer выполняется в браузере. DOMParser превращает строку HTML во временный document, после чего можно удалить служебные элементы и заменить code blocks.
export function prepareHtmlForPreview(
html: string
): string {
if (typeof document === "undefined") {
return html;
}
const parser = new DOMParser();
const doc = parser.parseFromString( html, "text/html" );
doc .querySelectorAll("select.ql-ui") .forEach((el) => el.remove());
// обработка code blocks
return doc.body.innerHTML;
}select.ql-ui относится к служебной разметке editor и в режиме чтения удаляется.Функция возвращает новую HTML-строку. Исходный contentHtml заметки остается без изменений.
Подсветка через highlight.js
highlight.js получает текст code block после преобразования Quill-разметки. Код сначала помещается через textContent, а затем библиотека меняет внутреннюю разметку для syntax highlighting.
const highlightIntoPre = (
doc: Document,
preEl: HTMLElement,
text: string
) => {
preEl.classList.add("hljs");
preEl.textContent = "";
const codeEl = doc.createElement("code");
codeEl.className = "hljs"; codeEl.textContent = text;
preEl.appendChild(codeEl);
try {
hljs.highlightElement(codeEl);
} catch {
// остаётся plain text
}
};При ошибке highlight.js элемент code остается с исходным текстом. Viewer не теряет содержимое блока.Язык в этом варианте явно не передается. highlight.js определяет подсветку своим механизмом.
CSS viewer
Редактор и режим чтения имеют разные ограничения по layout. Toolbar в viewer нет, зато длинный текст и code blocks не должны расширять правую панель.
.wb-viewer .ql-editor { height: 100%; overflow-y: auto; padding: 14px;
overflow-x: hidden; overflow-wrap: anywhere; word-break: break-word; min-width: 0; }
.wb-viewer .ql-editor pre {
max-width: 100%;
overflow-x: auto;
}Обычный текст переносится внутри контейнера. У pre остается горизонтальный scroll.
Для подсвеченного кода используется отдельный фон и padding.
.wb-viewer pre.hljs {
background: #0b1220;
border-radius: 10px;
padding: 12px;
overflow-x: auto;
max-width: 100%;
}
.wb-viewer pre.hljs code.hljs {
background: transparent;
padding: 0;
}Фон принадлежит pre, а highlight.js окрашивает токены внутри code.
Списки также требуют отдельной проверки. Quill CSS может задавать собственные маркеры, поэтому viewer возвращает обычные disc и decimal.
.wb-viewer .ql-editor ul,
.wb-viewer .ql-editor ol {
padding-left: 1.1rem !important;
margin: 0.5rem !important;
list-style-position: inside;
}
.wb-viewer .ql-editor ul > li { list-style: disc; }
.wb-viewer .ql-editor ol > li { list-style: decimal; }
.wb-viewer .ql-editor li::before {
content: none !important;
}Editor и viewer используют одну HTML-модель, но режим чтения получает собственные правила layout и списков.
Проверка
Проверка начинается со смены заметок. Открываем одну заметку, ставим курсор в editor, затем выбираем другую через ?note=. Новый note.id должен создать новый экземпляр Quill с другим contentHtml.
Autosave проверяется серией изменений без пауз длиннее 650 мс. До завершения server action отображается сохранение…. После успешного ответа меняются updatedAt и статус сохранено. Ошибка action переводит статус в ошибка сохранения.
Code block проверяется отдельно в editor и viewer. В заметку добавляется несколько строк кода, затем включается режим просмотра. Quill-разметка преобразуется в pre > code, highlight.js добавляет подсветку, длинная строка остается внутри горизонтального scroll.
Для примеров в статье использован проект Workbench.Теги:• nextjs
• typescript
• app router
• quill
• wysiwyg
• autosave
• highlight.js
• server actions
• react
• веб-разработкаХабы:• Веб-разработка
• TypeScript
• ReactJS
Получайте больше инсайтов о систематизации бизнеса
Подписывайтесь на Telegram-канал Business Operations — ежедневные материалы о бизнес-процессах, операционном управлении и повышении эффективности
💬 Подписаться на канал→ Оригинальная статья