diff --git a/packages/client/components/markdown/plugins/spoiler.tsx b/packages/client/components/markdown/plugins/spoiler.tsx index c7342ad42..a60cca4a3 100644 --- a/packages/client/components/markdown/plugins/spoiler.tsx +++ b/packages/client/components/markdown/plugins/spoiler.tsx @@ -7,22 +7,22 @@ import { visit } from "unist-util-visit"; const Spoiler = styled("span", { base: { - padding: "0 2px", + display: "inline-block", + padding: "0 6px", borderRadius: "var(--borderRadius-md)", }, variants: { shown: { true: { - color: "var(--md-sys-color-inverse-on-surface)", - background: "var(--md-sys-color-inverse-surface)", + background: "var(--md-sys-color-inverse-on-surface)", }, false: { cursor: "pointer", userSelect: "none", color: "transparent", - background: "#151515", + background: "var(--md-sys-color-on-secondary-fixed-variant)", - "> *": { + "& *": { opacity: 0, pointerEvents: "none", }, @@ -47,106 +47,95 @@ export function RenderSpoiler(props: { ); } -export const remarkSpoiler: Plugin = () => (tree) => { - visit( - tree, - "paragraph", - ( - node: { - children: ( - | { type: "text"; value: string } - | { type: "paragraph"; children: unknown[] } - | { type: "spoiler"; children: unknown[] } - )[]; - }, - _idx, - _parent, - ) => { - // Visitor state - let searchingForEnd = -1; - let spoilerContent: object[] = []; - - // Visit all children of paragraphs - for (let i = 0; i < node.children.length; i++) { - const child = node.children[i]; - - // Find the next text element to start a spoiler from - if (child.type === "text") { - const components = child.value.split("||"); - if (components.length === 1) continue; // no spoilers - - // Handle terminating spoiler tag - if (searchingForEnd !== -1) { - // Get all preceding elements - const elements = node.children.splice( - searchingForEnd, - i - searchingForEnd, - ); - - // Create a spoiler - node.children.splice(i, 0, { - type: "spoiler", - children: [ - ...spoilerContent, - ...elements, - { - type: "text", - value: components.shift(), - }, - ], - }); - - // Adjust our current index - i += elements.length + 1; - - searchingForEnd = -1; - spoilerContent = []; - } - - // Replace current child with next component - child.value = components.shift()!; - - // Check how many spoilers we have to process - const spillOver = components.length % 2 === 1; - const innerElements = (components.length - (spillOver ? 1 : 0)) / 2; +type ParentNode = { + type: "string"; + children: ( + | { type: "text"; value: string } + | { type: "paragraph" | "spoiler"; children: Node[] } + )[]; +}; - // Convert inner elements into spoilers - if (innerElements) { - for (let j = 0; j < innerElements; j++) { - node.children.splice( - i + 1, - 0, +export const remarkSpoiler: Plugin = () => (tree) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const tNodes = (tree as any).children; + let spillover: Node[] | null; + let spoilerStart = -1; + let spoilerText: string; + + visit(tree, "paragraph", (node: ParentNode, tIdx) => { + // Visit all children of paragraphs + for (let i = 0, s, sl; i < node.children.length; ++i) { + const child = node.children[i]; + + // Find the next text element to start a spoiler from + if (child.type === "text") { + const spoilers = child.value.split("||"); + if (spoilers.length === 1) continue; //No spoilers + node.children.splice(i, 1); //Delete this node + + //Parse spoiler start & end + for (s = 1, sl = spoilers.length; s < sl; ++s) { + if (spoilerStart !== -1) { + //End spoiler + const sText = spoilers[s - 1], + elements = node.children.splice(spoilerStart, i - spoilerStart), + inject = [ { type: "spoiler", children: [ - { - type: "text", - value: components.shift(), - }, + ...(spoilerText! + ? [{ type: "text", value: spoilerText }] + : []), + ...(spillover || []), + ...elements, + ...(sText && (spillover || i !== spoilerStart) + ? [{ type: "text", value: spoilers[s - 1] }] + : []), ], }, - { - type: "text", - value: components.shift()!, - }, - ); - - i += 2; - } - } - - // Update state if we are looking for the end of a spoiler - if (spillOver) { - searchingForEnd = i + 1; - spoilerContent.push({ - type: "text", - value: components.pop(), - }); + ...(spoilers[s] ? [{ type: "text", value: spoilers[s] }] : []), + ]; + + //Inject spoiler + // eslint-disable-next-line @typescript-eslint/no-explicit-any + node.children.splice(spoilerStart, 0, ...(inject as any)); + i = spoilerStart + inject.length - 1; + + spoilerStart = -1; + spillover = null; + } else { + //Inject non-spoiler text + if (spoilers[s - 1]) + node.children.splice(i++, 0, { + type: "text", + value: spoilers[s - 1], + }); + + //Start spoiler + spoilerStart = i; + spoilerText = spoilers[s]; } } } - }, - ); + } + + //Spillover to next parent node + if (spoilerStart !== -1) { + if (!spillover) spillover = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + spillover.push(...(node.children.splice(spoilerStart) as any)); + spoilerStart = 0; + } + + //Append excess spillover + if (spillover && tIdx === tNodes.length - 1) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (node.children as any[]).push( + ...(spoilerText! ? [{ type: "text", value: spoilerText! }] : []), + ...spillover, + ); + } + }); }; export const spoilerHandler: Handler = (h, node) => { diff --git a/packages/client/components/markdown/sanitise.ts b/packages/client/components/markdown/sanitise.ts index bc9b93b4e..6c962500c 100644 --- a/packages/client/components/markdown/sanitise.ts +++ b/packages/client/components/markdown/sanitise.ts @@ -7,12 +7,12 @@ const RE_RECURSIVE = /** * Regex for matching multi-line blockquotes */ -const RE_BLOCKQUOTE = /^([^\S\r\n]*>[^\n]+\n?)+/gm; +const RE_BLOCKQUOTE = /^(?:[^\S\r\n]*>[^\n]+\n?)+/gm; /** * Regex for matching HTML tags */ -const RE_HTML_TAGS = /^(<\/?[a-zA-Z0-9]+>)(.*$)/gm; +const RE_HTML_TAGS = /^(?:<\/?[a-zA-Z0-9]+>)(?:.*$)/gm; /** * Regex for matching empty lines @@ -30,6 +30,8 @@ const RE_PLUS = /^\s*\+(?:$|[^+])/gm; const RE_CODEBLOCK_EMPTY_LINE_FIX = /(?<=`{3}[\s\S]*)\n\uF800\n(?=[\s\S]*`{3})/gm; +const RE_SPOILER_URL = /(https?:\/\/[\w_.~!*''();:@&=+$,/?#[%-]+)\|\|/; + /** * Sanitise Markdown input before rendering * @param content Input string @@ -44,12 +46,12 @@ export function sanitise(content: string) { // Append empty character if string starts with html tag // This is to avoid inconsistencies in rendering Markdown inside/after HTML tags // https://github.com/revoltchat/revite/issues/733 - .replace(RE_HTML_TAGS, (match) => `\uF800${match}`) + .replace(RE_HTML_TAGS, "\uF800$&") // Append empty character if line starts with a plus // which would usually open a new list but we want // to avoid that behaviour in our case. - .replace(RE_PLUS, (match) => `\uF800${match}`) + .replace(RE_PLUS, "\uF800$&") // Replace empty lines with non-breaking space // because remark renderer is collapsing empty @@ -62,7 +64,10 @@ export function sanitise(content: string) { .replace(RE_CODEBLOCK_EMPTY_LINE_FIX, "") // Ensure empty line after blockquotes for correct rendering - .replace(RE_BLOCKQUOTE, (match) => `${match}\n`) + .replace(RE_BLOCKQUOTE, "$&\n") + + // Prevent spoilers from breaking on links at end + .replace(RE_SPOILER_URL, "$1 ||") ); }