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>
);
}
@@ -53,6 +53,7 @@ import {
Subpages,
Heading,
Highlight,
Spoiler,
Indent,
UniqueID,
SharedStorage,
@@ -116,6 +117,7 @@ import mentionRenderItems from "@/features/editor/components/mention/mention-sug
import { ReactNodeViewRenderer, ReactMarkViewRenderer } from "@tiptap/react";
import MentionView from "@/features/editor/components/mention/mention-view.tsx";
import LinkView from "@/features/editor/components/link/link-view.tsx";
import SpoilerView from "@/features/editor/components/spoiler/spoiler-view.tsx";
import i18n from "@/i18n.ts";
import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
import EmojiCommand from "./emoji-command";
@@ -237,6 +239,11 @@ export const mainExtensions = [
Highlight.configure({
multicolor: true,
}),
Spoiler.configure({}).extend({
addMarkView() {
return ReactMarkViewRenderer(SpoilerView);
},
}),
Typography,
TrailingNode,
GlobalDragHandle.configure({
@@ -14,6 +14,7 @@
@import "./mention.css";
@import "./ordered-list.css";
@import "./highlight.css";
@import "./spoiler.css";
@import "./indent.css";
@import "./columns.css";
@import "./status.css";
@@ -0,0 +1,21 @@
.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;
}
.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);
}
}