feat(#246): inline spoiler mark (blur + click-reveal, lossless Markdown) #259
Reference in New Issue
Block a user
Delete Branch "feat/246-spoiler"
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): TipTap-марка
spoiler, рендерится как<span data-spoiler="true" class="spoiler">…</span>, замазывается blur'ом и раскрывается по клику (состояние «раскрыт» — UI-only классis-revealed, в документ не пишется). closes #246Реализовано ровно по принятым в issue решениям (марка, а не узел; lossless Markdown через сырой inline-HTML; mark-view по паттерну
link):Spoiler(inclusive:false, командыset/toggle/unsetSpoiler, input-rule||text||), экспорт; lossless turndown-правило (<span data-spoiler="true">…</span>); round-trip тест.SpoilerView(mark-view,ReactMarkViewRenderer), регистрация вextensions.ts, кнопка в bubble-menu (только в editable), CSS (blur +@media printраскрытие), i18n en/ru.Spoilerдобавлен вtiptapExtensions(collaboration.util.ts), чтобы марка переживала HTML↔JSON export/index/import/Yjs; тест, что публичный шар спойлер НЕ зачищает (вместе сcomment).How verified
Прогнал на стенде (worktree с общими node_modules):
packages/editor-ext:npx vitest run→ 199 passed (5 spoiler) + 3 expected-fail (предсуществующиеit.failsв turndown.dataloss);npx tsc --build— clean.apps/server:npx jest src/core/share/ src/collaboration/ -w=2→ 186 passed (-w=2из-за OOM дефолтных воркеров в стенде, не падение кода); целевойshare-spoiler-keep.spec.ts— 2/2.apps/client:npx tsc --noEmit→ exit 0.generateHTML/generateJSON+htmlToMarkdown/markdownToHtml(JSON→HTML→MD→HTML→JSON, марка сохраняется, +кейс bold×spoiler); share-keep идёт через реальный seamprepareContentForShare → removeMarkTypeFromDoc(doc,'comment')и анти-вырожденным ассертом доказывает, чтоcommentстрипается, аspoilerостаётся.Checklist
Реализовал инлайновый спойлер (#246) по принятому в issue плану — внутренний архитект-ревью (мой review-субагент) сошёлся, вердикт APPROVE.
Что сделано (13 файлов):
packages/editor-ext/src/lib/spoiler/spoiler.ts:Mark,inclusive:false,parseHTML span[data-spoiler],renderHTML data-spoiler="true"+class, командыset/toggle/unsetSpoiler, input-rule||text||. Экспорт вindex.ts.SpoilerView(mark-view,ReactMarkViewRenderer— паттерн Link), регистрация вextensions.ts(addMarkView), кнопка Spoiler вbubble-menu.tsx(только editable;readonly-bubble-menuне трогал — в read-only кнопки нет, но клик-раскрытие через mark-view работает),styles/spoiler.css(blur +.is-revealed+@media printраскрытие) + импорт вstyles/index.css, i18n en/ru.turndown.utils.ts(raw<span data-spoiler="true">…</span>, как уhtmlEmbed). Импорт «бесплатный» (marked пропускает inline-HTML → generateJSON восстанавливает марку).Spoilerдобавлен вtiptapExtensions(collaboration.util.ts) — критично, иначе марка терялась бы на export/index/import/Yjs.Без хоткея: предложенный
Mod-Shift-sзанят Strike,Mod-Shift-h— Highlight (проверил node_modules). Эргономику закрывают input-rule||text||+ кнопка bubble-menu.||…||как Markdown-ИМПОРТ-алиас — вне MVP (step 10 issue), не делал.Проверка на стенде:
vitest run— 199 passed (5 spoiler) + 3 ожидаемыхit.fails;tsc --build— clean.jest src/core/share/ src/collaboration/ -w=2— 186 passed;share-spoiler-keep.spec.ts— 2/2 (-w=2из-за OOM дефолтных воркеров стенда).tsc --noEmit— exit 0.prepareContentForShare→removeMarkTypeFromDoc(doc,'comment')с анти-вырожденным ассертом (comment стрипается, spoiler остаётся).Развилка (non-blocker, на твоё решение): mark-view раскрывается по клику без клавиатурной доступности (
role/tabIndex/onKeyDown). Не добавил намеренно: марка оборачивает РЕДАКТИРУЕМЫЙ inline-контент, иtabIndex/role="button"на каждом спойлере перехватил бы Tab-навигацию при редактировании (каждый спойлер стал бы tab-stop) — регресс UX. Поведение совпадает с Telegram/Discord (там тоже клик). Контент при этом доступен скринридерам (CSS blur не прячет от AT). Если нужна клавиатурная активация в read-only — сделаю отдельным заходом аккуратно (без перехвата Tab в редакторе).Безопасность: спойлер — только визуальное сокрытие; текст есть в JSON/HTML/MD/поиске/payload шара — это не секрет (как и сказано в issue). Отражу в тултипе/доках, если решишь, что нужно.
PR #259,
closes #246. Поставилreview/needs.Ревью
188c5f506— раунд 1 (9 аспектов, включая COHERENCE). Цель #246: инлайновый спойлер (mark, blur + click-reveal, lossless markdown). Внутренний self-review кодера (agent_coder) не учитываю — единственный ревьюер agent_reviewer.Вердикт: CHANGES (2 пункта). Реализация в целом крепкая и проверена по рискам — но один из двух обязательных синков схемы пропущен (тихая потеря марки), и нет changelog-записи. Отвечай по id.
Что проверено и ЧИСТО:
addAttributes/getAttrs(чужие атрибуты дропаются схемой — инъекции атрибута нет),renderHTMLхардкодитdata-spoiler="true"/class="spoiler", контент эскейпится tiptap'ом (нетdangerouslySetInnerHTML); raw-HTML passthrough marked'а — предсуществующее поведение, нового сюрфейса нет; share-keep — это косметический blur, НЕ контроль доступа (сервер отдаёт текст; правильно понято авторами); input-rule regex без ReDoS.countSpoilerMarks===1(ловит и потерю, и дубль) + точный span + кейс bold×spoiler.inclusive:falseкак уlink, reveal — только React-useState, в документ/markdownis-revealedне утекает.data-spoiler/classидентичны в editor-ext/client/turndown/сервере;Spoilerв серверномtiptapExtensions(collaboration.util.ts) — единый источник для Yjs/import/export/persistence; share стрипает толькоcomment(тест анти-вакуозен: comment 1→0, spoiler остаётся 1 в одном доке).span[data-spoiler]не коллизит с существующими span-парсерами (mention/status/mathInline/comment поdata-type/data-comment-id); turndown-фильтр узкий;||text||не шадоуит таблицы/существующие input-rules; CSS scoped к.spoiler. security/regressions/architecture/test-coverage/stability/coherence — LGTM.Что сделать
F1 [blocking] [conventions/data-loss] Зеркалить марку
spoilerв vendored-схему MCP — иначе спойлер ТИХО теряется на пути через встроенный/mcp—packages/mcp/src/lib/docmost-schema.ts:1161(+packages/mcp/src/lib/markdown-converter.ts:141/157)AGENTS.md:266 — обязательное правило: «packages/mcp НЕ зависит от editor-ext; держит свою зеркальную копию схемы — синхронизировать вручную при изменении схемы документа». PR добавил
spoilerв editor-ext + client + серверныйtiptapExtensions, но MCP-зеркало НЕ обновил (грэпspoilerпоpackages/mcp/src/— пусто; вdocmostExtensionsесть Comment/Highlight, но не Spoiler; вmarkdown-converter.tsswitch имеетcase "highlight"/case "comment", но неcase "spoiler"). Следствие конкретное: любой документ со спойлером, проходящий через встроенный/mcp(схема строится один раз изdocmostExtensionsи переиспользуется в diff / collaboration write-back / markdown-export), теряет нераспознанную марку — скрытый текст лишается спойлера на MCP-опосредованных чтениях/записях. Это ровно тот класс поломки, про который предупреждают комментарии в самом файле дляcomment. Editor-ext↔server синк ты сделал — пропущен второй обязательный, editor-ext↔MCP.Fix: добавить марку
SpoilerвdocmostExtensions(docmost-schema.ts), определив локально (MCP не импортирует editor-ext) по образцу тамошнихComment/TextStyle: namespoiler,parseHTMLнаspan[data-spoiler],renderHTMLсdata-spoiler="true"+class="spoiler"; иcase "spoiler"в mark-switchmarkdown-converter.ts, эмитящий тот же lossless<span data-spoiler="true">…</span>, как делаютcomment/highlight.F2 [documentation] Нет записи в CHANGELOG для пользовательской фичи —
CHANGELOG.md(секция## [Unreleased]→### Added)Репо консистентно логирует каждую user-facing фичу в
[Unreleased] → Addedс номером PR в конце ((#198),(#222),(#228); недавние #233/#253 — так же). PR добавляет видимую возможность редактора (спойлер: blur+click-reveal, кнопка bubble-menu, input-rule||text||, lossless markdown), но записи нет — на релизе changelog недосчитается фичи.Fix: добавить буллет под
## [Unreleased]→### Addedпро инлайновый спойлер (blur с click-to-reveal, тоггл в bubble-menu, Discord-стиль||text||, сохраняется lossless через Markdown и на публичных шарах), завершив ссылкой(#259)в стиле соседних записей.Маркер обновлён (reviewed_head=188c5f506, verdict=changes). После правок верни
review/needs.Спасибо — оба пункта закрыл.
F1: fixed (blocking, потеря данных) — зеркалил марку
spoilerв vendored-схемуpackages/mcp(правило AGENTS.md:266 — mcp не зависит от editor-ext, держит свою копию схемы; я синканул editor-ext↔server, но пропустил editor-ext↔mcp). Сделал:docmost-schema.ts: локальная маркаSpoiler(Mark.create,name:"spoiler",inclusive:false,parseHTML span[data-spoiler],renderHTML data-spoiler="true"+class:"spoiler") по образцу тамошнихComment/Highlight(без импорта editor-ext); добавлена в массивdocmostExtensionsрядом сComment.markdown-converter.ts:case "spoiler"в mark-switch, эмитит РОВНО<span data-spoiler="true">${content}</span>— байт-в-байт как turndown-правило editor-ext (MD от клиента и от MCP идентичны → ре-импорт восстанавливает марку).build/lib/*.js(tsc --build).Теперь документ со спойлером не теряет марку на путях через
/mcp(diff / collaboration write-back / markdown-export).F2: fixed — добавил буллет в
CHANGELOG.md[Unreleased] → Addedпро инлайновый спойлер (blur + click-reveal, тоггл в bubble-menu,||text||, lossless через Markdown и на публичных шарах), ссылка(#259).DROP (set/unsetSpoiler) — оставил, согласен: set/toggle/unset — идиоматичное трио TipTap-марки (как у link/highlight).
Проверка на стенде:
packages/mcptsc --build— clean; полный MCP-сьютnode --test test/unit/*.test.mjs test/mock/*.test.mjs— 384/384 pass (вкл. новый spoiler round-trip тест: json→md проверяет наличие<span data-spoiler="true">…</span>, md→json черезmarkdownToProseMirrorпроверяет, что марка выжила — падает без фикса по обеим осям);packages/editor-exttsc --build— clean. Внутренний ревью независимо подтвердил байт-идентичность зеркала editor-ext↔mcp и не-вакуумность теста.Коммит
f9d8a6ed. Вернулreview/needs.Ревью
f9d8a6ede— раунд после фиксов F1/F2 (9 аспектов, включая COHERENCE).Вердикт: PASS. F1 и F2 закрыты и проверены ПО КОДУ независимо.
F1 (data-loss: марка не зеркалилась в MCP) — ЗАКРЫТ. Проверено coherence/security/regressions/stability/test-coverage:
Spoilerвpackages/mcp/src/lib/docmost-schema.tsбайт-эквивалентна editor-ext-марке (name:"spoiler",inclusive:false,parseHTML span[data-spoiler],renderHTML data-spoiler="true"+class:"spoiler"), определена локально по образцу тамошнихComment/TextStyle(mcp не импортирует editor-ext), добавлена вdocmostExtensionsрядом сComment→ встроенный/mcpgenerateJSONтеперь восстанавливает марку, а не дропает.case "spoiler"вmarkdown-converter.tsэмитит тот же lossless<span data-spoiler="true">…</span>, что и editor-ext turndown (turndown.utils.ts) — расхождения между MCP-экспортом и основным приложением нет.docmost-md-roundtrip.test.mjsне-вакуозен: ассертит И эмитнутый span (export), И восстановленную марку с точным текстом черезfindTextWithMark(import) — падает, если case убрать (export) или марку выкинуть из схемы — ровно баг F1 (import).${textContent}в case — КОНСИСТЕНТНО со всеми соседними марками (bold/highlight/comment тоже не эскейпят внутренний текст, только атрибуты); марка безaddAttributes/getAttrs→ крафченые атрибуты СТРИПАЮТСЯ при парсинге, не проносятся (тот же идиом, что уComment). Нового сюрфейса нет.break, существующие доки безdata-spoilerпарсятся как раньше.F2 (CHANGELOG) — ЗАКРЫТ. Запись в
[Unreleased] → Addedточно описывает фичу (blur+click-reveal, тоггл bubble-menu,||text||, lossless markdown как raw span, kept on public shares), стиль и(#259)как у соседних.security / stability / regressions / test-coverage / conventions / documentation / simplification / architecture / coherence — LGTM. Марка теперь консистентна во ВСЕХ слоях: editor-ext, client, серверный tiptapExtensions, и mcp. Все находки PR #259 закрыты.
Объективные проверки: новый MCP-тест в worktree ревью сам прогнать не смог (висячий pnpm-symlink
@hocuspocus/provider— артефакт окружения, не код); тест статически верифицирован не-вакуозным и корректным четырьмя аспектами, реализация байт-идентична доказанным паттернам editor-ext/comment, кодер отчитался о зелёном прогоне на стенде. Готово к мержу.Маркер
reviewed_headобновлён наf9d8a6ede.