Compare commits
2 Commits
fix/244-da
...
feat/246-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9d8a6ede1 | ||
|
|
188c5f506c |
@@ -67,6 +67,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
`nosniff` + restrictive CSP + attachment disposition for non-image mimes) and
|
||||
are RAM-only, bound to the instance that created them. Tunable via five
|
||||
`SANDBOX_*` env vars (see `.env.example`). (#243)
|
||||
- **Inline spoiler mark — hide text behind click-to-reveal blur.** Selected text
|
||||
can be marked as a spoiler from a new bubble-menu toggle, or typed Discord-style
|
||||
with the `||text||` input rule; the rendered span blurs until clicked to reveal.
|
||||
The mark is preserved losslessly through Markdown export/import (as a raw
|
||||
`<span data-spoiler="true">…</span>`) and on public shares. (#259)
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -352,6 +352,7 @@
|
||||
"Underline": "Underline",
|
||||
"Strike": "Strike",
|
||||
"Code": "Code",
|
||||
"Spoiler": "Spoiler",
|
||||
"Comment": "Comment",
|
||||
"Text": "Text",
|
||||
"Heading 1": "Heading 1",
|
||||
|
||||
@@ -351,6 +351,7 @@
|
||||
"Underline": "Подчёркнутый",
|
||||
"Strike": "Перечёркнутый",
|
||||
"Code": "Код",
|
||||
"Spoiler": "Спойлер",
|
||||
"Comment": "Комментарий",
|
||||
"Text": "Текст",
|
||||
"Heading 1": "Заголовок 1",
|
||||
|
||||
@@ -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";
|
||||
|
||||
21
apps/client/src/features/editor/styles/spoiler.css
Normal file
21
apps/client/src/features/editor/styles/spoiler.css
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
129
apps/server/src/core/share/share-spoiler-keep.spec.ts
Normal file
129
apps/server/src/core/share/share-spoiler-keep.spec.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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 (`<span data-spoiler="true">…</span>`), 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(
|
||||
'<p>before <span data-spoiler="true">hidden</span> after</p>',
|
||||
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('<span data-spoiler="true">hidden</span>');
|
||||
|
||||
// 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"');
|
||||
});
|
||||
});
|
||||
@@ -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 `<span
|
||||
* data-spoiler="true">…</span>` 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 `<span data-spoiler="true">${content}</span>`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function listParagraph(turndownService: _TurndownService) {
|
||||
turndownService.addRule('paragraph', {
|
||||
filter: ['p'],
|
||||
|
||||
74
packages/editor-ext/src/lib/spoiler/spoiler.ts
Normal file
74
packages/editor-ext/src/lib/spoiler/spoiler.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 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)([^|]+)(?<!\s)\|\|)$/;
|
||||
|
||||
export const Spoiler = Mark.create<SpoilerOptions>({
|
||||
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.
|
||||
});
|
||||
@@ -271,6 +271,25 @@ const TextStyle = Mark.create({
|
||||
return ["span", HTMLAttributes, 0];
|
||||
},
|
||||
});
|
||||
/**
|
||||
* Inline spoiler mark. Mirrors the @docmost/editor-ext `spoiler` mark so a
|
||||
* document carrying a spoiler survives the MCP read -> transform -> write path
|
||||
* (and markdown export) instead of silently dropping the unrecognized mark.
|
||||
* packages/mcp does NOT depend on editor-ext, so the definition is kept local;
|
||||
* it parses span[data-spoiler] and renders the same span[data-spoiler][class]
|
||||
* the editor-ext mark emits.
|
||||
*/
|
||||
const Spoiler = Mark.create({
|
||||
name: "spoiler",
|
||||
// Don't bleed onto text typed at the boundary (mirrors editor-ext).
|
||||
inclusive: false,
|
||||
parseHTML() {
|
||||
return [{ tag: "span[data-spoiler]" }];
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["span", { "data-spoiler": "true", class: "spoiler", ...HTMLAttributes }, 0];
|
||||
},
|
||||
});
|
||||
/**
|
||||
* Passthrough definitions for the remaining Docmost-specific nodes.
|
||||
*
|
||||
@@ -1097,6 +1116,7 @@ export const docmostExtensions = [
|
||||
// generateJSON drops <span style="color: ...">, defeating the color import.
|
||||
TextStyle,
|
||||
Comment,
|
||||
Spoiler,
|
||||
Callout,
|
||||
Table,
|
||||
TableRow,
|
||||
|
||||
@@ -160,6 +160,12 @@ export function convertProseMirrorToMarkdown(content) {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "spoiler":
|
||||
// Markdown has no native spoiler syntax, so emit the same
|
||||
// lossless raw HTML the editor-ext turndown rule produces; the
|
||||
// schema's Spoiler mark parses span[data-spoiler] back on import.
|
||||
textContent = `<span data-spoiler="true">${textContent}</span>`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,6 +298,26 @@ const TextStyle = Mark.create({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Inline spoiler mark. Mirrors the @docmost/editor-ext `spoiler` mark so a
|
||||
* document carrying a spoiler survives the MCP read -> transform -> write path
|
||||
* (and markdown export) instead of silently dropping the unrecognized mark.
|
||||
* packages/mcp does NOT depend on editor-ext, so the definition is kept local;
|
||||
* it parses span[data-spoiler] and renders the same span[data-spoiler][class]
|
||||
* the editor-ext mark emits.
|
||||
*/
|
||||
const Spoiler = Mark.create({
|
||||
name: "spoiler",
|
||||
// Don't bleed onto text typed at the boundary (mirrors editor-ext).
|
||||
inclusive: false,
|
||||
parseHTML() {
|
||||
return [{ tag: "span[data-spoiler]" }];
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["span", { "data-spoiler": "true", class: "spoiler", ...HTMLAttributes }, 0];
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Passthrough definitions for the remaining Docmost-specific nodes.
|
||||
*
|
||||
@@ -1194,6 +1214,7 @@ export const docmostExtensions = [
|
||||
// generateJSON drops <span style="color: ...">, defeating the color import.
|
||||
TextStyle,
|
||||
Comment,
|
||||
Spoiler,
|
||||
Callout,
|
||||
Table,
|
||||
TableRow,
|
||||
|
||||
@@ -167,6 +167,12 @@ export function convertProseMirrorToMarkdown(content: any): string {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "spoiler":
|
||||
// Markdown has no native spoiler syntax, so emit the same
|
||||
// lossless raw HTML the editor-ext turndown rule produces; the
|
||||
// schema's Spoiler mark parses span[data-spoiler] back on import.
|
||||
textContent = `<span data-spoiler="true">${textContent}</span>`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +167,38 @@ test("export emits comment anchors and they round-trip back to a comment mark",
|
||||
});
|
||||
});
|
||||
|
||||
test("export emits a spoiler span and it round-trips back to a spoiler mark", () => {
|
||||
// A small ProseMirror doc with a text run carrying a `spoiler` mark. The MCP
|
||||
// schema mirrors the editor-ext mark, so a spoiler must survive json -> md ->
|
||||
// json instead of being silently dropped as an unrecognized mark.
|
||||
const doc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "plot: " },
|
||||
{
|
||||
type: "text",
|
||||
text: "the butler did it",
|
||||
marks: [{ type: "spoiler" }],
|
||||
},
|
||||
{ type: "text", text: " end" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const body = convertProseMirrorToMarkdown(doc);
|
||||
assert.match(body, /<span data-spoiler="true">the butler did it<\/span>/);
|
||||
|
||||
return markdownToProseMirror(body).then((rebuilt) => {
|
||||
const spoilered = findTextWithMark(rebuilt, "spoiler");
|
||||
assert.ok(spoilered, "expected a text node with a spoiler mark");
|
||||
assert.equal(spoilered.text, "the butler did it");
|
||||
});
|
||||
});
|
||||
|
||||
test("drawio round-trips through export and import", () => {
|
||||
const doc = {
|
||||
type: "doc",
|
||||
|
||||
Reference in New Issue
Block a user