From 188c5f506cbffa323edd4578a68cc66fbcdf2815 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Mon, 29 Jun 2026 23:22:30 +0300 Subject: [PATCH] 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 , 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) --- .../public/locales/en-US/translation.json | 1 + .../public/locales/ru-RU/translation.json | 1 + .../components/bubble-menu/bubble-menu.tsx | 8 ++ .../components/spoiler/spoiler-view.tsx | 20 +++ .../features/editor/extensions/extensions.ts | 7 + .../src/features/editor/styles/index.css | 1 + .../src/features/editor/styles/spoiler.css | 21 +++ .../src/collaboration/collaboration.util.ts | 2 + .../src/core/share/share-spoiler-keep.spec.ts | 129 ++++++++++++++++++ packages/editor-ext/src/index.ts | 1 + .../lib/markdown/utils/spoiler.marked.test.ts | 128 +++++++++++++++++ .../src/lib/markdown/utils/turndown.utils.ts | 24 ++++ .../editor-ext/src/lib/spoiler/spoiler.ts | 74 ++++++++++ 13 files changed, 417 insertions(+) create mode 100644 apps/client/src/features/editor/components/spoiler/spoiler-view.tsx create mode 100644 apps/client/src/features/editor/styles/spoiler.css create mode 100644 apps/server/src/core/share/share-spoiler-keep.spec.ts create mode 100644 packages/editor-ext/src/lib/markdown/utils/spoiler.marked.test.ts create mode 100644 packages/editor-ext/src/lib/spoiler/spoiler.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 45234831..b47da764 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -352,6 +352,7 @@ "Underline": "Underline", "Strike": "Strike", "Code": "Code", + "Spoiler": "Spoiler", "Comment": "Comment", "Text": "Text", "Heading 1": "Heading 1", diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index efdf28ce..88629662 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -351,6 +351,7 @@ "Underline": "Подчёркнутый", "Strike": "Перечёркнутый", "Code": "Код", + "Spoiler": "Спойлер", "Comment": "Комментарий", "Text": "Текст", "Heading 1": "Заголовок 1", diff --git a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx index 5c590487..651cb2f6 100644 --- a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx +++ b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx @@ -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 = (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 = (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 = { diff --git a/apps/client/src/features/editor/components/spoiler/spoiler-view.tsx b/apps/client/src/features/editor/components/spoiler/spoiler-view.tsx new file mode 100644 index 00000000..e92eefce --- /dev/null +++ b/apps/client/src/features/editor/components/spoiler/spoiler-view.tsx @@ -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 ( + setRevealed((v) => !v)} + > + + + ); +} diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index 63855097..4c375cf6 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -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({ diff --git a/apps/client/src/features/editor/styles/index.css b/apps/client/src/features/editor/styles/index.css index 52d9268e..030c0180 100644 --- a/apps/client/src/features/editor/styles/index.css +++ b/apps/client/src/features/editor/styles/index.css @@ -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"; diff --git a/apps/client/src/features/editor/styles/spoiler.css b/apps/client/src/features/editor/styles/spoiler.css new file mode 100644 index 00000000..90c5f5c8 --- /dev/null +++ b/apps/client/src/features/editor/styles/spoiler.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); + } +} diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts index a894aaea..7970051b 100644 --- a/apps/server/src/collaboration/collaboration.util.ts +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -36,6 +36,7 @@ import { Mention, Subpages, Highlight, + Spoiler, Indent, UniqueID, Columns, @@ -82,6 +83,7 @@ export const tiptapExtensions = [ Superscript, SubScript, Highlight, + Spoiler, Typography, TrailingNode, TextStyle, diff --git a/apps/server/src/core/share/share-spoiler-keep.spec.ts b/apps/server/src/core/share/share-spoiler-keep.spec.ts new file mode 100644 index 00000000..bcc68b13 --- /dev/null +++ b/apps/server/src/core/share/share-spoiler-keep.spec.ts @@ -0,0 +1,129 @@ +import { ShareService } from './share.service'; + +// Sibling of share-comment-strip.spec.ts. The public-share sanitizer strips ONLY +// `comment` marks (internal-team metadata) via removeMarkTypeFromDoc(doc, +// 'comment'). The `spoiler` mark is legitimate authored content (hidden text the +// reader clicks to reveal) and MUST survive the share-strip — otherwise public +// readers would see the secret in plain text or lose it entirely. +// +// We drive the SAME real seam the comment-strip test uses: +// updatePublicAttachments -> prepareContentForShare -> removeMarkTypeFromDoc. + +const WS = 'ws-1'; +const PAGE = 'page-1'; + +function buildService() { + const shareRepo = { findById: jest.fn() }; + const pageRepo = { findById: jest.fn() }; + const pagePermissionRepo = { + hasRestrictedAncestor: jest.fn(async () => false), + }; + const tokenService = { + generateAttachmentToken: jest.fn(async () => 'tok'), + }; + const workspaceRepo = { + findById: jest.fn(async () => ({ id: WS, settings: { htmlEmbed: true } })), + }; + + return new ShareService( + shareRepo as any, + pageRepo as any, + pagePermissionRepo as any, + {} as any, // db (unused on this path) + tokenService as any, + {} as any, // transclusionService (unused) + workspaceRepo as any, + ); +} + +// Text carrying a `spoiler` mark (no attributes; revealed state is UI-only). +function spoilerText(text: string) { + return { + type: 'text', + text, + marks: [{ type: 'spoiler' }], + }; +} + +// Text carrying a `comment` mark with an id (the thing that DOES get stripped). +function commentedText(text: string, commentId: string) { + return { + type: 'text', + text, + marks: [{ type: 'comment', attrs: { commentId, resolved: false } }], + }; +} + +async function sanitize(content: any) { + const service = buildService(); + return service.updatePublicAttachments({ + id: PAGE, + workspaceId: WS, + content, + } as any); +} + +function countMarks(doc: any, type: string): number { + let count = 0; + const walk = (node: any) => { + if (!node || typeof node !== 'object') return; + if (Array.isArray(node.marks)) { + for (const mark of node.marks) { + if (mark?.type === type) count++; + } + } + if (Array.isArray(node.content)) node.content.forEach(walk); + }; + walk(doc); + return count; +} + +describe('ShareService keeps spoiler marks on public shares (real code)', () => { + it('does NOT strip a spoiler mark', async () => { + const content = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'visible ' }, spoilerText('hidden')], + }, + ], + }; + + expect(countMarks(content, 'spoiler')).toBe(1); + + const out = await sanitize(content); + + // The spoiler mark survives the share-strip. + expect(countMarks(out, 'spoiler')).toBe(1); + expect(JSON.stringify(out)).toContain('hidden'); + }); + + it('strips comment marks but keeps spoiler marks in the same doc', async () => { + const content = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + commentedText('reviewed', 'cmt-1'), + { type: 'text', text: ' and ' }, + spoilerText('secret'), + ], + }, + ], + }; + + expect(countMarks(content, 'comment')).toBe(1); + expect(countMarks(content, 'spoiler')).toBe(1); + + const out = await sanitize(content); + + // comment is removed, spoiler is preserved. + expect(countMarks(out, 'comment')).toBe(0); + expect(countMarks(out, 'spoiler')).toBe(1); + const serialized = JSON.stringify(out); + expect(serialized).not.toContain('cmt-1'); + expect(serialized).toContain('secret'); + }); +}); diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts index 08888ddf..a2f1d0eb 100644 --- a/packages/editor-ext/src/index.ts +++ b/packages/editor-ext/src/index.ts @@ -25,6 +25,7 @@ export * from "./lib/subpages"; export * from "./lib/transclusion"; export * from "./lib/page-embed"; export * from "./lib/highlight"; +export * from "./lib/spoiler/spoiler"; export * from "./lib/indent"; export * from "./lib/heading/heading"; export * from "./lib/unique-id"; diff --git a/packages/editor-ext/src/lib/markdown/utils/spoiler.marked.test.ts b/packages/editor-ext/src/lib/markdown/utils/spoiler.marked.test.ts new file mode 100644 index 00000000..32b77de9 --- /dev/null +++ b/packages/editor-ext/src/lib/markdown/utils/spoiler.marked.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect } from "vitest"; +import { getSchema } from "@tiptap/core"; +import { generateHTML, generateJSON } from "@tiptap/html"; +import { Document } from "@tiptap/extension-document"; +import { Paragraph } from "@tiptap/extension-paragraph"; +import { Text } from "@tiptap/extension-text"; +import { Bold } from "@tiptap/extension-bold"; +import { htmlToMarkdown } from "./turndown.utils"; +import { markdownToHtml } from "./marked.utils"; +import { Spoiler } from "../../spoiler/spoiler"; + +// The spoiler mark has no native Markdown syntax, so it is preserved losslessly +// as raw inline HTML (``), the same approach +// htmlEmbed uses. This test drives the full editor round-trip: +// JSON -> HTML -> Markdown -> HTML -> JSON +// and asserts the `spoiler` mark survives end to end. We use the same +// getSchema + @tiptap/html generateHTML/generateJSON utilities the other +// editor-ext schema tests use. + +const extensions = [Document, Paragraph, Text, Bold, Spoiler]; + +function html(md: string): string { + const out = markdownToHtml(md); + if (typeof out !== "string") throw new Error("expected sync string output"); + return out; +} + +// Count text nodes carrying a `spoiler` mark anywhere in a ProseMirror JSON doc. +function countSpoilerMarks(doc: any): number { + let count = 0; + const walk = (node: any) => { + if (!node || typeof node !== "object") return; + if (Array.isArray(node.marks)) { + for (const mark of node.marks) { + if (mark?.type === "spoiler") count++; + } + } + if (Array.isArray(node.content)) node.content.forEach(walk); + }; + walk(doc); + return count; +} + +describe("Spoiler mark schema", () => { + it("registers the spoiler mark in the schema", () => { + const schema = getSchema(extensions); + expect(schema.marks.spoiler).toBeTruthy(); + }); + + it("recovers the spoiler mark from span[data-spoiler] (HTML -> JSON)", () => { + const json = generateJSON( + '

before hidden after

', + extensions, + ); + expect(countSpoilerMarks(json)).toBe(1); + }); + + it("emits data-spoiler + class on render (JSON -> HTML)", () => { + const doc = { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "hidden", + marks: [{ type: "spoiler" }], + }, + ], + }, + ], + }; + const out = generateHTML(doc, extensions); + expect(out).toContain('data-spoiler="true"'); + expect(out).toContain('class="spoiler"'); + }); +}); + +describe("Spoiler Markdown round-trip is lossless", () => { + const docWith = (textNode: any) => ({ + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "before " }, textNode, { type: "text", text: " after" }], + }, + ], + }); + + it("preserves the spoiler mark through JSON -> MD -> HTML -> JSON", () => { + const startDoc = docWith({ + type: "text", + text: "hidden", + marks: [{ type: "spoiler" }], + }); + + // JSON -> HTML + const html1 = generateHTML(startDoc, extensions); + expect(html1).toContain('data-spoiler="true"'); + + // HTML -> Markdown (raw inline HTML, lossless) + const md = htmlToMarkdown(html1); + expect(md).toContain('hidden'); + + // MD -> HTML -> JSON (mark restored via parseHTML) + const endJson = generateJSON(html(md), extensions); + expect(countSpoilerMarks(endJson)).toBe(1); + // The visible text survives. + expect(JSON.stringify(endJson)).toContain("hidden"); + }); + + it("keeps the spoiler intact when it intersects a bold mark", () => { + const startDoc = docWith({ + type: "text", + text: "secret", + marks: [{ type: "bold" }, { type: "spoiler" }], + }); + + const md = htmlToMarkdown(generateHTML(startDoc, extensions)); + expect(md).toContain("data-spoiler=\"true\""); + + const endJson = generateJSON(html(md), extensions); + expect(countSpoilerMarks(endJson)).toBe(1); + // Bold survives alongside the spoiler. + expect(JSON.stringify(endJson)).toContain('"bold"'); + }); +}); diff --git a/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts b/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts index 172786a3..36062b49 100644 --- a/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts +++ b/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts @@ -65,6 +65,7 @@ export function htmlToMarkdown(html: string): string { mathBlock, iframeEmbed, htmlEmbed, + spoiler, image, video, footnoteReference, @@ -101,6 +102,29 @@ function htmlEmbed(turndownService: _TurndownService) { }); } +/** + * Serialize the `spoiler` inline mark to lossless raw inline HTML. + * + * Markdown has no native spoiler syntax, so we emit the same `` the mark renders. `marked` passes inline raw HTML + * through untouched, and `generateJSON` restores the mark via its parseHTML, so + * the round-trip MD -> HTML -> JSON keeps the spoiler intact. The UI-only + * `is-revealed` state is never serialized. + */ +function spoiler(turndownService: _TurndownService) { + turndownService.addRule('spoiler', { + filter: function (node: HTMLInputElement) { + return ( + node.nodeName === 'SPAN' && + node.getAttribute('data-spoiler') === 'true' + ); + }, + replacement: function (content: string) { + return `${content}`; + }, + }); +} + function listParagraph(turndownService: _TurndownService) { turndownService.addRule('paragraph', { filter: ['p'], diff --git a/packages/editor-ext/src/lib/spoiler/spoiler.ts b/packages/editor-ext/src/lib/spoiler/spoiler.ts new file mode 100644 index 00000000..22a06f95 --- /dev/null +++ b/packages/editor-ext/src/lib/spoiler/spoiler.ts @@ -0,0 +1,74 @@ +import { Mark, markInputRule, mergeAttributes } from "@tiptap/core"; + +export interface SpoilerOptions { + HTMLAttributes: Record; +} + +declare module "@tiptap/core" { + interface Commands { + spoiler: { + setSpoiler: () => ReturnType; + toggleSpoiler: () => ReturnType; + unsetSpoiler: () => ReturnType; + }; + } +} + +// Discord-style `||text||` input rule. Requires a non-space right after the +// opening `||` and a non-space right before the closing `||` so empty/padded +// markers don't match. +const inputRegex = /(?:^|\s)(\|\|(?!\s)([^|]+)(?({ + name: "spoiler", + + // Don't bleed onto text typed at the boundary (mirrors link). + inclusive: false, + + 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), + }; + }, + + addInputRules() { + return [markInputRule({ find: inputRegex, type: this.type })]; + }, + + // No addKeyboardShortcuts: the issue's proposed `Mod-Shift-s` is already taken + // by the built-in Strike mark (and `Mod-Shift-h` by Highlight). The `||text||` + // input rule plus the bubble-menu button cover ergonomics, so we omit a hotkey + // rather than collide with an existing one. +});