[feature][editor] Инлайновый спойлер (скрытый текст, Telegram/Discord-стиль): mark + клик-раскрытие + lossless Markdown #246

Open
opened 2026-06-28 03:55:59 +03:00 by vvzvlad · 0 comments
Owner

Summary

Добавить инлайновый спойлер — скрытый текст в духе Telegram/Discord: выделенный фрагмент строки замазывается (blur), а по клику раскрывается. Заполняет пробел: сворачиваемый блок уже есть (Toggle block / details), а вот инлайнового «спрятать кусок текста внутри абзаца» — нет (поиск по spoiler в apps/ и packages/ пуст).

Это визуальный механизм, а не редактирование/секрет: текст спойлера остаётся в документе, экспорте и поиске (см. раздел «Безопасность»).

Принятые решения

  • Тип: TipTap Mark spoiler (инлайновый, как highlight/link), а НЕ узел. Сворачиваемый блок остаётся за details (Toggle block).
  • Хранение/HTML: <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 не требуется.
  • Реализация view: addMarkView() => ReactMarkViewRenderer(SpoilerView) — ровно тот же паттерн, что у link (LinkExtensionLinkView). Локальный useState(revealed) + клик-хендлер; контент марки рендерится в contentDOM.
  • Markdown — lossless через сырой HTML: канонический вид при экспорте — <span data-spoiler="true">текст</span> (тот же приём raw-HTML, что для htmlEmbed/video/image-caption). Импорт «бесплатный»: marked пропускает инлайновый сырой HTML, дальше generateJSON восстанавливает марку через parseHTML. Серверный код HTML↔JSON менять не нужно (кроме регистрации расширения, см. ниже).
  • Эргономика ввода: input-rule ||текст|| → 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 будет нечитаемая замазка.

План реализации (декомпозиция)

  1. NEW packages/editor-ext/src/lib/spoiler/spoiler.ts — марка:
    import { Mark, markInputRule, mergeAttributes } from "@tiptap/core";
    
    export interface SpoilerOptions { HTMLAttributes: Record<string, unknown>; }
    
    declare module "@tiptap/core" {
      interface Commands<ReturnType> {
        spoiler: {
          setSpoiler: () => ReturnType;
          toggleSpoiler: () => ReturnType;
          unsetSpoiler: () => ReturnType;
        };
      }
    }
    
    // ||text|| -> spoiler (Discord-style)
    const inputRegex = /(?:^|\s)(\|\|(?!\s)([^|]+)(?<!\s)\|\|)$/;
    
    export const Spoiler = Mark.create<SpoilerOptions>({
      name: "spoiler",
      inclusive: false, // do not bleed onto text typed at the boundary (like link)
      addOptions() { return { HTMLAttributes: {} }; },
      parseHTML() { return [{ tag: "span[data-spoiler]" }]; },
      renderHTML({ HTMLAttributes }) {
        return ["span", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes,
          { "data-spoiler": "true", class: "spoiler" }), 0];
      },
      addCommands() {
        return {
          setSpoiler: () => ({ commands }) => commands.setMark(this.name),
          toggleSpoiler: () => ({ commands }) => commands.toggleMark(this.name),
          unsetSpoiler: () => ({ commands }) => commands.unsetMark(this.name),
        };
      },
      addKeyboardShortcuts() {
        // NOTE: verify no collision with existing shortcuts before locking this in.
        return { "Mod-Shift-s": () => this.editor.commands.toggleSpoiler() };
      },
      addInputRules() { return [markInputRule({ find: inputRegex, type: this.type })]; },
    });
    
  2. packages/editor-ext/src/index.tsexport * from "./lib/spoiler/spoiler";
  3. NEW apps/client/src/features/editor/components/spoiler/spoiler-view.tsx — mark-view с клик-раскрытием (по образцу link-view.tsx):
    import { MarkViewContent, MarkViewRendererProps } from "@tiptap/react";
    import { useState } from "react";
    
    export default function SpoilerView({ editor }: MarkViewRendererProps) {
      const [revealed, setRevealed] = useState(false);
      return (
        <span
          className={revealed ? "spoiler is-revealed" : "spoiler"}
          data-spoiler="true"
          // Reveal on click; in read-only this is the only interaction.
          onClick={() => setRevealed((v) => !v)}
        >
          <MarkViewContent />
        </span>
      );
    }
    
  4. apps/client/src/features/editor/extensions/extensions.ts — импорт Spoiler из @docmost/editor-ext (в общий блок импорта марок) и регистрация:
    Spoiler.configure({}).extend({
      addMarkView() { return ReactMarkViewRenderer(SpoilerView); },
    }),
    
  5. 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.
  6. CSS — NEW apps/client/src/features/editor/styles/spoiler.css (импортировать рядом с highlight.css):
    .spoiler {
      background: rgba(0,0,0,0.85);
      border-radius: 0.25em;
      cursor: pointer;
      filter: blur(0.3em);
      transition: filter 0.15s ease;
      user-select: none; /* avoid accidental select-to-read while hidden */
    }
    .spoiler.is-revealed { filter: none; background: rgba(125,125,125,0.18); user-select: auto; }
    @media print { .spoiler { filter: none; background: rgba(125,125,125,0.18); } } /* no click on paper/PDF */
    
  7. packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts — правило экспорта (lossless raw-HTML, рядом с htmlEmbed/image):
    turndownService.addRule("spoiler", {
      filter: (node) => node.nodeName === "SPAN" && node.getAttribute("data-spoiler") === "true",
      // Keep it lossless: emit raw inline HTML so MD -> HTML -> JSON restores the mark.
      replacement: (content) => `<span data-spoiler="true">${content}</span>`,
    });
    
  8. apps/server/src/collaboration/collaboration.util.ts — добавить Spoiler в массив tiptapExtensions (импорт из @docmost/editor-ext). Критично для экспорта/индекса/импорта/Yjs.
  9. i18n — ключ перевода "Spoiler" в локалях (en + ru как минимум).
  10. (Стретч, вне MVP) 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-хендлеров проблем быть не должно, но проверить).
  • Печать/PDF/статический HTML-экспорт — раскрывать под @media print (см. CSS), иначе нечитаемо.
  • Пересечение марок — spoiler поверх bold/italic/code/link и наоборот; убедиться, что blur не ломает вложенный code-фон и кликабельность ссылки внутри спойлера.
  • Keyboard shortcutMod-Shift-S предложен условно; перед фиксацией проверить отсутствие коллизий с уже занятыми (highlight/strike/прочее).
  • Read-only бабл-меню — кнопка спойлера не должна появляться в readonly-bubble-menu.tsx; раскрытие по клику при этом работать должно.
  • Copy/paste — копирование раскрытого/нераскрытого фрагмента должно сохранять марку (через HTML clipboard) и не «утаскивать» класс is-revealed (он не в renderHTML, так что ок).

Безопасность (обязательно задокументировать в UI/доках)

Спойлер — только визуальное сокрытие. Текст присутствует в JSON-документе, в экспортируемых HTML/Markdown, в индексе полнотекстового поиска и в payload публичного шара. Это не редактирование/секрет/приватность. Нужно явно проговорить ожидание (тултип/доки), чтобы спойлером не прятали чувствительные данные.

Definition of Done

  • Марка spoiler создана, экспортирована, зарегистрирована на клиенте (с mark-view) и на сервере (tiptapExtensions).
  • Бабл-меню: кнопка toggle со статусом isActive; input-rule ||text|| работает.
  • Клик раскрывает/скрывает спойлер в редакторе, read-only и публичном шаре.
  • Markdown round-trip lossless: JSON → MD (<span data-spoiler="true">…</span>) → HTML → JSON сохраняет марку; покрыто тестом (по образцу turndown.* тестов).
  • Публичный шар сохраняет спойлер (не зачищается вместе с comment); покрыто тестом рядом с share-comment-strip.spec.ts.
  • Печать/PDF раскрывает спойлер (@media print).
  • Нет коллизии горячей клавиши; ключ i18n «Spoiler» добавлен.
  • В UI/доках отражено, что спойлер — визуальное сокрытие, не секрет.

Out of scope

  • Блочный спойлер (уже покрыт Toggle block / details).
  • Спойлер как механизм приватности/прав доступа (контент не шифруется и не вырезается).
  • Раскрытие по наведению/таймеру, пер-спойлерные настройки стиля.
## Summary Добавить **инлайновый спойлер** — скрытый текст в духе Telegram/Discord: выделенный фрагмент строки замазывается (blur), а по клику раскрывается. Заполняет пробел: сворачиваемый **блок** уже есть (Toggle block / `details`), а вот инлайнового «спрятать кусок текста внутри абзаца» — нет (поиск по `spoiler` в `apps/` и `packages/` пуст). Это **визуальный** механизм, а не редактирование/секрет: текст спойлера остаётся в документе, экспорте и поиске (см. раздел «Безопасность»). ## Принятые решения - **Тип:** TipTap **Mark** `spoiler` (инлайновый, как `highlight`/`link`), а НЕ узел. Сворачиваемый блок остаётся за `details` (Toggle block). - **Хранение/HTML:** `<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 не требуется. - **Реализация view:** `addMarkView() => ReactMarkViewRenderer(SpoilerView)` — ровно тот же паттерн, что у `link` (`LinkExtension` → `LinkView`). Локальный `useState(revealed)` + клик-хендлер; контент марки рендерится в `contentDOM`. - **Markdown — lossless через сырой HTML:** канонический вид при экспорте — `<span data-spoiler="true">текст</span>` (тот же приём raw-HTML, что для `htmlEmbed`/video/image-caption). Импорт «бесплатный»: `marked` пропускает инлайновый сырой HTML, дальше `generateJSON` восстанавливает марку через `parseHTML`. Серверный код HTML↔JSON менять не нужно (кроме регистрации расширения, см. ниже). - **Эргономика ввода:** input-rule `||текст||` → 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 будет нечитаемая замазка. ## План реализации (декомпозиция) 1. **NEW `packages/editor-ext/src/lib/spoiler/spoiler.ts`** — марка: ```ts import { Mark, markInputRule, mergeAttributes } from "@tiptap/core"; export interface SpoilerOptions { HTMLAttributes: Record<string, unknown>; } declare module "@tiptap/core" { interface Commands<ReturnType> { spoiler: { setSpoiler: () => ReturnType; toggleSpoiler: () => ReturnType; unsetSpoiler: () => ReturnType; }; } } // ||text|| -> spoiler (Discord-style) const inputRegex = /(?:^|\s)(\|\|(?!\s)([^|]+)(?<!\s)\|\|)$/; export const Spoiler = Mark.create<SpoilerOptions>({ name: "spoiler", inclusive: false, // do not bleed onto text typed at the boundary (like link) addOptions() { return { HTMLAttributes: {} }; }, parseHTML() { return [{ tag: "span[data-spoiler]" }]; }, renderHTML({ HTMLAttributes }) { return ["span", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { "data-spoiler": "true", class: "spoiler" }), 0]; }, addCommands() { return { setSpoiler: () => ({ commands }) => commands.setMark(this.name), toggleSpoiler: () => ({ commands }) => commands.toggleMark(this.name), unsetSpoiler: () => ({ commands }) => commands.unsetMark(this.name), }; }, addKeyboardShortcuts() { // NOTE: verify no collision with existing shortcuts before locking this in. return { "Mod-Shift-s": () => this.editor.commands.toggleSpoiler() }; }, addInputRules() { return [markInputRule({ find: inputRegex, type: this.type })]; }, }); ``` 2. **`packages/editor-ext/src/index.ts`** — `export * from "./lib/spoiler/spoiler";` 3. **NEW `apps/client/src/features/editor/components/spoiler/spoiler-view.tsx`** — mark-view с клик-раскрытием (по образцу `link-view.tsx`): ```tsx import { MarkViewContent, MarkViewRendererProps } from "@tiptap/react"; import { useState } from "react"; export default function SpoilerView({ editor }: MarkViewRendererProps) { const [revealed, setRevealed] = useState(false); return ( <span className={revealed ? "spoiler is-revealed" : "spoiler"} data-spoiler="true" // Reveal on click; in read-only this is the only interaction. onClick={() => setRevealed((v) => !v)} > <MarkViewContent /> </span> ); } ``` 4. **`apps/client/src/features/editor/extensions/extensions.ts`** — импорт `Spoiler` из `@docmost/editor-ext` (в общий блок импорта марок) и регистрация: ```ts Spoiler.configure({}).extend({ addMarkView() { return ReactMarkViewRenderer(SpoilerView); }, }), ``` 5. **`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`. 6. **CSS** — NEW `apps/client/src/features/editor/styles/spoiler.css` (импортировать рядом с `highlight.css`): ```css .spoiler { background: rgba(0,0,0,0.85); border-radius: 0.25em; cursor: pointer; filter: blur(0.3em); transition: filter 0.15s ease; user-select: none; /* avoid accidental select-to-read while hidden */ } .spoiler.is-revealed { filter: none; background: rgba(125,125,125,0.18); user-select: auto; } @media print { .spoiler { filter: none; background: rgba(125,125,125,0.18); } } /* no click on paper/PDF */ ``` 7. **`packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts`** — правило экспорта (lossless raw-HTML, рядом с `htmlEmbed`/`image`): ```ts turndownService.addRule("spoiler", { filter: (node) => node.nodeName === "SPAN" && node.getAttribute("data-spoiler") === "true", // Keep it lossless: emit raw inline HTML so MD -> HTML -> JSON restores the mark. replacement: (content) => `<span data-spoiler="true">${content}</span>`, }); ``` 8. **`apps/server/src/collaboration/collaboration.util.ts`** — добавить `Spoiler` в массив `tiptapExtensions` (импорт из `@docmost/editor-ext`). **Критично** для экспорта/индекса/импорта/Yjs. 9. **i18n** — ключ перевода `"Spoiler"` в локалях (en + ru как минимум). 10. **(Стретч, вне MVP)** `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-хендлеров проблем быть не должно, но проверить). - **Печать/PDF/статический HTML-экспорт** — раскрывать под `@media print` (см. CSS), иначе нечитаемо. - **Пересечение марок** — spoiler поверх `bold`/`italic`/`code`/`link` и наоборот; убедиться, что blur не ломает вложенный `code`-фон и кликабельность ссылки внутри спойлера. - **Keyboard shortcut** — `Mod-Shift-S` предложен условно; перед фиксацией проверить отсутствие коллизий с уже занятыми (highlight/strike/прочее). - **Read-only бабл-меню** — кнопка спойлера не должна появляться в `readonly-bubble-menu.tsx`; раскрытие по клику при этом работать должно. - **Copy/paste** — копирование раскрытого/нераскрытого фрагмента должно сохранять марку (через HTML clipboard) и не «утаскивать» класс `is-revealed` (он не в `renderHTML`, так что ок). ## Безопасность (обязательно задокументировать в UI/доках) Спойлер — **только визуальное** сокрытие. Текст присутствует в JSON-документе, в экспортируемых HTML/Markdown, в индексе полнотекстового поиска и в payload публичного шара. Это **не** редактирование/секрет/приватность. Нужно явно проговорить ожидание (тултип/доки), чтобы спойлером не прятали чувствительные данные. ## Definition of Done - [ ] Марка `spoiler` создана, экспортирована, зарегистрирована на клиенте (с mark-view) и на сервере (`tiptapExtensions`). - [ ] Бабл-меню: кнопка toggle со статусом `isActive`; input-rule `||text||` работает. - [ ] Клик раскрывает/скрывает спойлер в редакторе, read-only и публичном шаре. - [ ] Markdown round-trip lossless: JSON → MD (`<span data-spoiler="true">…</span>`) → HTML → JSON сохраняет марку; покрыто тестом (по образцу `turndown.*` тестов). - [ ] Публичный шар сохраняет спойлер (не зачищается вместе с `comment`); покрыто тестом рядом с `share-comment-strip.spec.ts`. - [ ] Печать/PDF раскрывает спойлер (`@media print`). - [ ] Нет коллизии горячей клавиши; ключ i18n «Spoiler» добавлен. - [ ] В UI/доках отражено, что спойлер — визуальное сокрытие, не секрет. ## Out of scope - Блочный спойлер (уже покрыт Toggle block / `details`). - Спойлер как механизм приватности/прав доступа (контент не шифруется и не вырезается). - Раскрытие по наведению/таймеру, пер-спойлерные настройки стиля.
vvzvlad added the feature label 2026-06-28 03:55:59 +03:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#246