feat(editor): inline spoiler mark (blur + click-reveal, lossless Markdown) (#246)

Add an inline spoiler (Telegram/Discord-style hidden text): a TipTap mark
`spoiler` rendered as <span data-spoiler="true" class="spoiler">, blurred via
CSS and revealed on click (UI-only is-revealed class, never persisted).

- packages/editor-ext: the Spoiler mark (inclusive:false, set/toggle/unset
  commands, ||text|| input rule), exported; a lossless turndown rule emitting
  raw inline HTML; round-trip test.
- apps/client: SpoilerView mark-view (ReactMarkViewRenderer, Link pattern),
  registration in extensions, bubble-menu toggle button (editable only), CSS
  (blur + @media print reveal), en/ru i18n.
- apps/server: register Spoiler in collaboration.util tiptapExtensions so the
  mark survives HTML<->JSON export/index/import/Yjs; a test proving the public
  share keeps the spoiler (it isn't stripped with comments).

No keyboard shortcut: the proposed Mod-Shift-s collides with Strike (and
Mod-Shift-h with Highlight); the ||text|| input rule + the bubble-menu button
cover ergonomics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-29 23:22:30 +03:00
parent e5a0f2d887
commit 188c5f506c
13 changed files with 417 additions and 0 deletions
@@ -9,6 +9,7 @@ import {
IconStrikethrough,
IconUnderline,
IconMessage,
IconEyeOff,
} from "@tabler/icons-react";
import clsx from "clsx";
import classes from "./bubble-menu.module.css";
@@ -74,6 +75,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
isStrike: ctx.editor.isActive("strike"),
isCode: ctx.editor.isActive("code"),
isComment: ctx.editor.isActive("comment"),
isSpoiler: ctx.editor.isActive("spoiler"),
};
},
});
@@ -109,6 +111,12 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
command: () => props.editor.chain().focus().toggleCode().run(),
icon: IconCode,
},
{
name: "Spoiler",
isActive: () => editorState?.isSpoiler,
command: () => props.editor.chain().focus().toggleSpoiler().run(),
icon: IconEyeOff,
},
];
const commentItem: BubbleMenuItem = {
@@ -0,0 +1,20 @@
import { MarkViewContent, MarkViewProps } from "@tiptap/react";
import { useState } from "react";
// Click-to-reveal spoiler. The revealed state is UI-only and is never written to
// the document: toggling only adds/removes the `is-revealed` class (CSS removes
// the blur). renderHTML never emits `is-revealed`, so it can't leak into the
// doc/clipboard. Works the same in editor, read-only and public-share views.
export default function SpoilerView(_props: MarkViewProps) {
const [revealed, setRevealed] = useState(false);
return (
<span
className={revealed ? "spoiler is-revealed" : "spoiler"}
data-spoiler="true"
onClick={() => setRevealed((v) => !v)}
>
<MarkViewContent />
</span>
);
}