[feature][editor] Инлайновый спойлер (скрытый текст, Telegram/Discord-стиль): mark + клик-раскрытие + lossless Markdown #246
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Добавить инлайновый спойлер — скрытый текст в духе Telegram/Discord: выделенный фрагмент строки замазывается (blur), а по клику раскрывается. Заполняет пробел: сворачиваемый блок уже есть (Toggle block /
details), а вот инлайнового «спрятать кусок текста внутри абзаца» — нет (поиск поspoilerвapps/иpackages/пуст).Это визуальный механизм, а не редактирование/секрет: текст спойлера остаётся в документе, экспорте и поиске (см. раздел «Безопасность»).
Принятые решения
spoiler(инлайновый, какhighlight/link), а НЕ узел. Сворачиваемый блок остаётся заdetails(Toggle block).<span data-spoiler="true" class="spoiler">…</span>. Атрибутов у марки нет (состояние «раскрыт» — это UI, в документ не пишется).parseHTML: span[data-spoiler],renderHTML— добавляетdata-spoiler="true"и класс.is-revealed; CSS убирает blur. Работает одинаково в редакторе, read-only и публичном шаре. Текст всегда editable (blur — этоfilter, каретка/ввод не блокируются), поэтому отдельной логики «раскрыть для редактирования» в MVP не требуется.addMarkView() => ReactMarkViewRenderer(SpoilerView)— ровно тот же паттерн, что уlink(LinkExtension→LinkView). ЛокальныйuseState(revealed)+ клик-хендлер; контент марки рендерится вcontentDOM.<span data-spoiler="true">текст</span>(тот же приём raw-HTML, что дляhtmlEmbed/video/image-caption). Импорт «бесплатный»:markedпропускает инлайновый сырой HTML, дальшеgenerateJSONвосстанавливает марку черезparseHTML. Серверный код HTML↔JSON менять не нужно (кроме регистрации расширения, см. ниже).||текст||→ spoiler (Discord-синтаксис) в редакторе. Markdown-алиас||…||при импорте — опциональный стретч (не входит в MVP).Архитектура
HTML↔JSON — почти «бесплатно», но нужна регистрация на сервере
Сервер гоняет документ через
generateHTML/generateJSONсо спискомtiptapExtensionsизapps/server/src/collaboration/collaboration.util.ts. МаркуSpoilerобязательно добавить и туда, иначе при экспорте HTML / индексировании / импорте / Yjs-раунд-трипе<span data-spoiler>будет распознан как неизвестный и потеряется. ProseMirror-сериализатор экранирует содержимое — XSS не вносим.Публичный шар сохраняет спойлер (это намеренно)
В
apps/server/src/core/share/share.service.tsдля шара снимается толькоcomment(removeMarkTypeFromDoc(doc, 'comment')).spoilerпод зачистку не попадает, поэтому в публичной странице спойлер остаётся (замазан, раскрывается по клику) — желаемое поведение. Менять share-санитайзер не нужно, но это надо явно подтвердить тестом.Два пути рендера blur
CSS-класс
.spoilerдолжен одинаково работать в трёх местах: редактор (editable), read-only и публичный шар, а также в статических экспортах (HTML/PDF/print). Поскольку все используют один и тот же набор расширений и стилей редактора, достаточно общего CSS. Edge-case печати/PDF: кликать негде, поэтому под@media printспойлер должен раскрываться (filter: none) — иначе в PDF будет нечитаемая замазка.План реализации (декомпозиция)
packages/editor-ext/src/lib/spoiler/spoiler.ts— марка:packages/editor-ext/src/index.ts—export * from "./lib/spoiler/spoiler";apps/client/src/features/editor/components/spoiler/spoiler-view.tsx— mark-view с клик-раскрытием (по образцуlink-view.tsx):apps/client/src/features/editor/extensions/extensions.ts— импортSpoilerиз@docmost/editor-ext(в общий блок импорта марок) и регистрация:apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx— пункт «Spoiler» (иконкаIconEyeOff),isActive: () => editor.isActive("spoiler"),command: () => editor.chain().focus().toggleSpoiler().run(); добавитьisSpoilerвeditorState.apps/client/src/features/editor/styles/spoiler.css(импортировать рядом сhighlight.css):packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts— правило экспорта (lossless raw-HTML, рядом сhtmlEmbed/image):apps/server/src/collaboration/collaboration.util.ts— добавитьSpoilerв массивtiptapExtensions(импорт из@docmost/editor-ext). Критично для экспорта/индекса/импорта/Yjs."Spoiler"в локалях (en + ru как минимум).packages/editor-ext/src/lib/markdown/utils/spoiler.marked.ts+ регистрация вmarked.utils.ts— поддержка||…||как Markdown-алиаса при импорте (по образцуmath-inline.marked.ts).Тонкости и edge-cases
inclusive: false— чтобы при вводе на границе марки текст не «прилипал» к спойлеру (как уlink); проверить курсор-boundary в Firefox (уlinkпод это есть отдельный плагин — у простой марки без contentDOM-хендлеров проблем быть не должно, но проверить).@media print(см. CSS), иначе нечитаемо.bold/italic/code/linkи наоборот; убедиться, что blur не ломает вложенныйcode-фон и кликабельность ссылки внутри спойлера.Mod-Shift-Sпредложен условно; перед фиксацией проверить отсутствие коллизий с уже занятыми (highlight/strike/прочее).readonly-bubble-menu.tsx; раскрытие по клику при этом работать должно.is-revealed(он не вrenderHTML, так что ок).Безопасность (обязательно задокументировать в UI/доках)
Спойлер — только визуальное сокрытие. Текст присутствует в JSON-документе, в экспортируемых HTML/Markdown, в индексе полнотекстового поиска и в payload публичного шара. Это не редактирование/секрет/приватность. Нужно явно проговорить ожидание (тултип/доки), чтобы спойлером не прятали чувствительные данные.
Definition of Done
spoilerсоздана, экспортирована, зарегистрирована на клиенте (с mark-view) и на сервере (tiptapExtensions).isActive; input-rule||text||работает.<span data-spoiler="true">…</span>) → HTML → JSON сохраняет марку; покрыто тестом (по образцуturndown.*тестов).comment); покрыто тестом рядом сshare-comment-strip.spec.ts.@media print).Out of scope
details).