/** * PlanCleanDiffView — Rendered/clean diff mode * * Shows the new plan content rendered as markdown, with colored left borders * indicating what changed. Annotation uses block-level hover (like code block * hover in Viewer) — no text selection, no web-highlighter. */ import React, { useEffect, useRef, useState, useCallback, createContext, useContext, useMemo } from "react"; import { parseMarkdownToBlocks, computeListIndices } from "../../utils/parser"; import { applyHighlight, codeBlockClassName, codeBlockKeepsLayout } from "../../utils/codeHighlight"; import { useFenceTheme } from "../../hooks/useFenceTheme"; import { ListItemBody } from "../ListItemBody"; import { BlockquoteBlock, BLOCKQUOTE_CLASS_NAME } from "../blocks/BlockquoteBlock"; import type { Block, Annotation, EditorMode, ImageAttachment } from "../../types"; import { AnnotationType } from "../../types"; import { foldUnchangedPlanDiff, type PlanDiffBlock, type PlanDiffFold, type InlineDiffToken, type InlineDiffWrap, } from "../../utils/planDiffEngine"; import type { QuickLabel } from "../../utils/quickLabels"; import { AnnotationToolbar } from "../AnnotationToolbar"; import { CommentPopover } from "../CommentPopover"; import { FloatingQuickLabelPicker } from "../FloatingQuickLabelPicker"; import { getIdentity } from "../../utils/identity"; /** * Which version a rendered chunk comes from: 'old' for a removed block and * the old half of a modified block, 'new' for everything else. */ export type PlanDiffSide = "old" | "new"; /** Host renderer for one parsed markdown block (see `renderBlock`). */ export type PlanDiffBlockRenderer = ( block: Block, orderedIndex: number | null, side: PlanDiffSide, ) => React.ReactNode; /** Provided only when a host passes `renderBlock`; absent, chunks render as always. */ const RenderBlockContext = createContext(null); interface PlanCleanDiffViewProps { blocks: PlanDiffBlock[]; annotations?: Annotation[]; onAddAnnotation?: (ann: Annotation) => void; onSelectAnnotation?: (id: string | null) => void; selectedAnnotationId?: string | null; mode?: EditorMode; /** * When true (default), modified blocks that passed the qualification gate * render with inline word-level highlights. When false, every modified * block falls back to the stacked old-struck / new-green layout — the * "Classic" diff view exposed in the mode switcher. */ wordLevel?: boolean; /** * Optional: render each parsed markdown block (headings, paragraphs, code, * tables, …) with the host's renderer instead of the view's built-in * simple one. Not used for inline word-level modified blocks. Absent, the * view renders exactly as before. */ renderBlock?: PlanDiffBlockRenderer; /** * Optional: a left gutter on every block naming its source lines * ("11–12", "old 15–16"; a modified block shows both). Needs the line * ranges `computePlanDiff` fills. Absent, no gutter. */ lineGutter?: boolean; /** * Optional: fold long runs of unchanged sections behind a "N unchanged * sections" row that expands in place (see `foldUnchangedPlanDiff`). */ foldUnchanged?: boolean; /** * Optional: classes for unchanged blocks, replacing the default dimming * (`opacity-60 hover:opacity-100 transition-opacity`). */ unchangedClassName?: string; } const DEFAULT_UNCHANGED_CLASS = "opacity-60 hover:opacity-100 transition-opacity"; export const PlanCleanDiffView: React.FC = ({ blocks, annotations = [], onAddAnnotation, onSelectAnnotation, selectedAnnotationId = null, mode = "selection", wordLevel = true, renderBlock, lineGutter = false, foldUnchanged = false, unchangedClassName, }) => { const modeRef = useRef(mode); const onAddAnnotationRef = useRef(onAddAnnotation); const hoverTimeoutRef = useRef | null>(null); const exitTimerRef = useRef | null>(null); const [hoveredBlock, setHoveredBlock] = useState<{ element: HTMLElement; block: PlanDiffBlock; index: number; diffContext: Annotation['diffContext']; } | null>(null); const [isExiting, setIsExiting] = useState(false); const [commentPopover, setCommentPopover] = useState<{ anchorEl: HTMLElement; contextText: string; initialText?: string; block: PlanDiffBlock; index: number; diffContext: Annotation['diffContext']; } | null>(null); const [quickLabelPicker, setQuickLabelPicker] = useState<{ anchorEl: HTMLElement; block: PlanDiffBlock; index: number; diffContext: Annotation['diffContext']; } | null>(null); useEffect(() => { modeRef.current = mode; }, [mode]); useEffect(() => { onAddAnnotationRef.current = onAddAnnotation; }, [onAddAnnotation]); // Clean up timers on unmount useEffect(() => { return () => { if (hoverTimeoutRef.current) clearTimeout(hoverTimeoutRef.current); if (exitTimerRef.current) clearTimeout(exitTimerRef.current); }; }, []); // Scroll to selected annotation's diff block // Only depends on selectedAnnotationId — annotations ref is read but not a trigger const annotationsRef = useRef(annotations); annotationsRef.current = annotations; useEffect(() => { if (!selectedAnnotationId) return; const ann = annotationsRef.current.find(a => a.id === selectedAnnotationId); if (!ann?.blockId?.startsWith('diff-block-')) return; const idx = ann.blockId.replace('diff-block-', ''); const el = document.querySelector(`[data-diff-block-index="${idx}"]`); if (!el) return; el.classList.add('annotation-highlight', 'focused'); el.scrollIntoView({ behavior: 'smooth', block: 'center' }); const timer = setTimeout(() => { el.classList.remove('annotation-highlight', 'focused'); }, 2000); return () => clearTimeout(timer); }, [selectedAnnotationId]); // Build set of annotated block IDs for highlight rings const annotatedBlockIds = React.useMemo(() => { const set = new Set(); annotations.forEach(ann => { if (ann.diffContext && ann.blockId) { set.add(ann.blockId); } }); return set; }, [annotations]); /** * Resolve content for a diff block section (handles modified blocks with * old/new sides). For inline-diff modified blocks — one clickable target * with diffContext 'modified' — we capture BOTH sides in git-diff shape * so comments about a struck-through deleted word preserve that word in * the exported feedback, instead of sending only the new content. */ const getBlockContent = useCallback((block: PlanDiffBlock, diffContext: Annotation['diffContext']) => { if (block.type === 'modified') { if (diffContext === 'removed') return block.oldContent || block.content; if ( diffContext === 'modified' && block.oldContent && block.oldContent !== block.content ) { return `- ${block.oldContent.trimEnd()}\n+ ${block.content.trimEnd()}`; } } return block.content; }, []); const createDiffAnnotation = useCallback(( block: PlanDiffBlock, index: number, diffContext: Annotation['diffContext'], type: AnnotationType, text?: string, images?: ImageAttachment[], isQuickLabel?: boolean, quickLabelTip?: string, ) => { const content = getBlockContent(block, diffContext); const now = Date.now(); const newAnnotation: Annotation = { id: `diff-${now}-${index}`, blockId: `diff-block-${index}`, startOffset: 0, endOffset: content.length, type, text, originalText: content, createdA: now, author: getIdentity(), images, diffContext, ...(isQuickLabel ? { isQuickLabel: true } : {}), ...(quickLabelTip ? { quickLabelTip } : {}), }; onAddAnnotationRef.current?.(newAnnotation); }, [getBlockContent]); // Hover handlers const handleHover = useCallback((element: HTMLElement, block: PlanDiffBlock, index: number, diffContext: Annotation['diffContext']) => { if (hoverTimeoutRef.current) { clearTimeout(hoverTimeoutRef.current); hoverTimeoutRef.current = null; } setIsExiting(false); if (!commentPopover && !quickLabelPicker) { setHoveredBlock({ element, block, index, diffContext }); } }, [commentPopover, quickLabelPicker]); const handleLeave = useCallback(() => { hoverTimeoutRef.current = setTimeout(() => { setIsExiting(true); exitTimerRef.current = setTimeout(() => { setHoveredBlock(null); setIsExiting(false); exitTimerRef.current = null; }, 150); }, 100); }, []); // Toolbar handlers const handleAnnotate = (type: AnnotationType) => { if (!hoveredBlock) return; createDiffAnnotation(hoveredBlock.block, hoveredBlock.index, hoveredBlock.diffContext, type); setHoveredBlock(null); setIsExiting(false); }; const handleQuickLabel = (label: QuickLabel) => { if (!hoveredBlock) return; createDiffAnnotation( hoveredBlock.block, hoveredBlock.index, hoveredBlock.diffContext, AnnotationType.COMMENT, `${label.emoji} ${label.text}`, undefined, true, label.tip ); setHoveredBlock(null); setIsExiting(false); }; const handleToolbarClose = () => { setHoveredBlock(null); setIsExiting(false); }; const handleRequestComment = (initialChar?: string) => { if (!hoveredBlock) return; const content = getBlockContent(hoveredBlock.block, hoveredBlock.diffContext); setCommentPopover({ anchorEl: hoveredBlock.element, contextText: content.slice(0, 80), initialText: initialChar, block: hoveredBlock.block, index: hoveredBlock.index, diffContext: hoveredBlock.diffContext, }); setHoveredBlock(null); }; const handleCommentSubmit = (text: string, images?: ImageAttachment[]) => { if (!commentPopover) return; createDiffAnnotation( commentPopover.block, commentPopover.index, commentPopover.diffContext, AnnotationType.COMMENT, text, images ); setCommentPopover(null); }; const handleCommentClose = useCallback(() => { setCommentPopover(null); }, []); const handleFloatingQuickLabel = useCallback((label: QuickLabel) => { if (!quickLabelPicker) return; createDiffAnnotation( quickLabelPicker.block, quickLabelPicker.index, quickLabelPicker.diffContext, AnnotationType.COMMENT, `${label.emoji} ${label.text}`, undefined, true, label.tip ); setQuickLabelPicker(null); }, [quickLabelPicker, createDiffAnnotation]); const handleQuickLabelPickerDismiss = useCallback(() => { setQuickLabelPicker(null); }, []); // Mode-aware click on hovered block const handleBlockClick = useCallback((block: PlanDiffBlock, index: number, element: HTMLElement, diffContext: Annotation['diffContext']) => { if (modeRef.current === 'redline') { createDiffAnnotation(block, index, diffContext, AnnotationType.DELETION); } else if (modeRef.current === 'quickLabel') { setQuickLabelPicker({ anchorEl: element, block, index, diffContext }); } else { // selection or comment → open the comment popover directly on click const content = getBlockContent(block, diffContext); setCommentPopover({ anchorEl: element, contextText: content.slice(0, 80), block, index, diffContext, }); } }, [createDiffAnnotation, getBlockContent]); // Check if a block index has been annotated (for highlight ring) const isBlockAnnotated = (index: number) => annotatedBlockIds.has(`diff-block-${index}`); // Fold state per fold key (expanded folds render their pieces in place). const [expandedFolds, setExpandedFolds] = useState>(() => new Set()); const toggleFold = useCallback((key: string) => { setExpandedFolds((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); }, []); const segments = useMemo( () => (foldUnchanged ? foldUnchangedPlanDiff(blocks) : null), [blocks, foldUnchanged], ); const renderDiffBlock = (block: PlanDiffBlock, index: number, key: React.Key) => { const rendered = ( handleHover(el, block, index, diffContext) : undefined} onLeave={onAddAnnotation ? handleLeave : undefined} onClick={onAddAnnotation ? (el, diffContext) => handleBlockClick(block, index, el, diffContext) : undefined} /> ); if (!lineGutter) return rendered; return ( {rendered} ); }; const renderFold = (fold: PlanDiffFold, index: number) => { const expanded = expandedFolds.has(fold.key); const noun = fold.sectionCount === 1 ? "section" : "sections"; const row = expanded ? ( ) : ( ); return ( {lineGutter ? ( {row} ) : ( row )} {expanded && fold.pieces.map((piece, i) => renderDiffBlock(piece, index, `${fold.key}-${i}`))} ); }; const body = segments ? segments.map((segment, i) => segment.kind === "fold" ? renderFold(segment, i) : renderDiffBlock(segment.block, segment.index, `seg-${i}`), ) : blocks.map((block, index) => renderDiffBlock(block, index, index)); return (
{renderBlock ? ( {body} ) : ( body )} {/* Block hover toolbar (selection mode) */} {hoveredBlock && !commentPopover && !quickLabelPicker && ( { if (hoverTimeoutRef.current) { clearTimeout(hoverTimeoutRef.current); hoverTimeoutRef.current = null; } setIsExiting(false); }} onMouseLeave={handleLeave} /> )} {/* Comment popover */} {commentPopover && ( )} {/* Quick label picker */} {quickLabelPicker && ( )}
); }; // --- Line gutter + fold row (optional seams) --- /** "11–12", or "14" for one line; null when the side has no lines. */ function rangeLabel(start?: number, end?: number): string | null { if (start === undefined) return null; return end !== undefined && end !== start ? `${start}–${end}` : `${start}`; } /** The gutter lines for one rendered diff block. */ function blockGutterLabels(block: PlanDiffBlock): string[] { const newLabel = rangeLabel(block.newStart, block.newEnd); const oldLabel = rangeLabel(block.oldStart, block.oldEnd); switch (block.type) { case "removed": return oldLabel ? [`old ${oldLabel}`] : []; case "modified": return [newLabel, oldLabel ? `old ${oldLabel}` : null].filter((l): l is string => !!l); default: return newLabel ? [newLabel] : []; } } /** A block beside its source-line gutter. The gutter is never selectable or part of a quote. */ // Baseline alignment puts the label beside the first line of text, past a // heading's top margin. const GutterRow: React.FC<{ labels: string[]; children: React.ReactNode }> = ({ labels, children }) => (
{children}
); const FoldChevron: React.FC<{ expanded: boolean }> = ({ expanded }) => ( ); // --- DiffBlockRenderer with hover support --- interface DiffBlockRendererProps { block: PlanDiffBlock; index: number; hoveredIndex: number | null; hoveredDiffContext?: Annotation['diffContext']; isBlockAnnotated: (index: number) => boolean; /** When false, force block-level fallback even if inlineTokens is populated. */ wordLevel: boolean; /** Replaces the default unchanged-block dimming classes when set. */ unchangedClassName?: string; onHover?: (element: HTMLElement, diffContext: Annotation['diffContext']) => void; onLeave?: () => void; onClick?: (element: HTMLElement, diffContext: Annotation['diffContext']) => void; } const DiffBlockRenderer: React.FC = ({ block, index, hoveredIndex, hoveredDiffContext, isBlockAnnotated, wordLevel, unchangedClassName, onHover, onLeave, onClick, }) => { const hoverProps = (diffContext: Annotation['diffContext']) => onHover ? { onMouseEnter: (e: React.MouseEvent) => onHover(e.currentTarget, diffContext), onMouseLeave: () => onLeave?.(), onClick: onClick ? (e: React.MouseEvent) => onClick(e.currentTarget, diffContext) : undefined, style: { cursor: 'pointer' } as React.CSSProperties, } : {}; const isHovered = (diffContext: Annotation['diffContext']) => hoveredIndex === index && hoveredDiffContext === diffContext; const ringClass = (diffContext: Annotation['diffContext']) => { if (isHovered(diffContext)) return 'ring-1 ring-primary/30 rounded'; if (isBlockAnnotated(index)) return 'ring-2 ring-accent rounded outline-offset-2'; return ''; }; switch (block.type) { case "unchanged": return (
); case "added": return (
); case "removed": return (
); case "modified": // When the engine populated inlineTokens, we render a single in-context // block with / spans inside the structural wrapper. Falls // back to the stacked strike-through rendering when tokens are absent // (gate rejected: code/table/structural mismatch/inline-code hazard). if (wordLevel && block.inlineTokens && block.inlineWrap) { return ( ); } return (
); default: return null; } }; // --- Shared block-rendering style helpers --- // Kept as module-scope constants so InlineModifiedBlock and SimpleBlockRenderer // share a single source of truth for heading/paragraph/list-item styling. const HEADING_STYLE_BY_LEVEL: Record = { 1: "text-2xl font-bold mb-4 mt-6 first:mt-0 tracking-tight", 2: "text-xl font-semibold mb-3 mt-8 text-foreground/90", 3: "text-base font-semibold mb-2 mt-6 text-foreground/80", }; const HEADING_STYLE_FALLBACK = "text-base font-semibold mb-2 mt-4"; const headingStyleFor = (level: number): string => HEADING_STYLE_BY_LEVEL[level] || HEADING_STYLE_FALLBACK; const PARAGRAPH_CLASS = "mb-4 leading-relaxed text-foreground/90 text-[15px]"; const LIST_ITEM_ROW_CLASS = "flex items-start gap-3 my-1.5"; const listItemIndentRem = (level: number): string => `${level * 1.25}rem`; const listItemTextClass = (isCheckbox: boolean, checked?: boolean): string => `text-sm leading-relaxed ${isCheckbox && checked ? "text-muted-foreground line-through" : "text-foreground/90"}`; // --- Inline word-diff renderer for modified blocks --- interface InlineModifiedBlockProps { tokens: InlineDiffToken[]; wrap: InlineDiffWrap; index: number; ringClass: string; hoverProps: { onMouseEnter?: (e: React.MouseEvent) => void; onMouseLeave?: () => void; onClick?: (e: React.MouseEvent) => void; style?: React.CSSProperties; }; } /** * Renders a 'modified' diff block in-context: one structural wrapper * (h1-h6, p, or list-item div) containing a single InlineMarkdown parse * over a unified string with / tags wrapping changed tokens. * Preserves markdown AST context across token boundaries (bold pairs, * links) which per-token rendering would break. */ const InlineModifiedBlock: React.FC = ({ tokens, wrap, index, ringClass, hoverProps, }) => { const unified = tokens .map((t) => { if (t.type === "added") return `${t.value}`; if (t.type === "removed") return `${t.value}`; return t.value; }) .join(""); // Modified blocks rendered inline carry BOTH additions and deletions, so // their border/background uses the amber "modified" class — not the green // "added" one. Inline / word highlights render on top unchanged. const wrapperBase = `plan-diff-modified transition-shadow ${ringClass}`; const { style: hoverStyle, ...hoverRest } = hoverProps; if (wrap.type === "heading") { const level = wrap.level || 1; const Tag = `h${level}` as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; return ( ); } if (wrap.type === "list-item") { const listLevel = wrap.listLevel || 0; const isCheckbox = wrap.checked !== undefined; return (
} />
); } // paragraph return (

); }; // --- Rendering components (unchanged from main) --- const MarkdownChunk: React.FC<{ content: string; side?: PlanDiffSide }> = ({ content, side = "new" }) => { const renderBlock = useContext(RenderBlockContext); const blocks = React.useMemo( () => parseMarkdownToBlocks(content), [content] ); // Compute ordered-list display indices across the entire chunk so every // list-item gets the right numeral even though we don't group here. // Non-list blocks pass through as `null` and act as streak-breaks — same // behavior as the main Viewer's per-group counter. const orderedIndices = React.useMemo( () => computeListIndices(blocks), [blocks] ); if (renderBlock) { return ( <> {blocks.map((block, i) => ( {renderBlock(block, orderedIndices[i], side)} ))} ); } return ( <> {blocks.map((block, i) => ( ))} ); }; const SimpleBlockRenderer: React.FC<{ block: Block; orderedIndex?: number | null }> = ({ block, orderedIndex }) => { switch (block.type) { case "heading": { const level = block.level || 1; const Tag = `h${level}` as keyof React.JSX.IntrinsicElements; return ( ); } case "blockquote": { // The document's quote renderer, without a block id (the diff view // keeps none): a prose quote renders as it always has, and a quote // with a code fence, a list or a nested quote renders them inside one // quote. Alerts keep the plain quote look they have always had here. if (block.alertKind) { const paragraphs = block.content.split(/\n\n+/); return (
{paragraphs.map((para, i) => (

0 ? "mt-2" : ""}>

))}
); } return ; } case "list-item": { const listLevel = block.level || 0; const isCheckbox = block.checked !== undefined; return (
} />
); } case "code": return ; case "hr": return
; case "table": { const lines = block.content.split('\n').filter(line => line.trim()); if (lines.length === 0) return null; const parseRow = (line: string): string[] => line.replace(/^\|/, '').replace(/\|$/, '').split(/(? cell.trim().replace(/\\\|/g, '|')); const headers = parseRow(lines[0]); const rows: string[][] = []; for (let i = 1; i < lines.length; i++) { const line = lines[i].trim(); if (/^[\|\-:\s]+$/.test(line)) continue; rows.push(parseRow(line)); } return (
{headers.map((header, i) => ( ))} {rows.map((row, rowIdx) => ( {row.map((cell, cellIdx) => ( ))} ))}
); } default: return (

); } }; const SimpleCodeBlock: React.FC<{ block: Block }> = ({ block }) => { const codeRef = useRef(null); const fenceTheme = useFenceTheme(); useEffect(() => { if (codeRef.current) { codeRef.current.className = codeBlockClassName(block.language); // Language-less fences stay plain (#1212) — applyHighlight never guesses. applyHighlight(codeRef.current, block.content, block.language, fenceTheme); } }, [block.content, block.language, fenceTheme]); return (
        
          {block.content}
        
      
{block.language && ( {block.language} )}
); }; /** * Block dangerous link protocols (javascript:, data:, vbscript:, file:) from * rendering as clickable anchors in the diff view. Plan content is attacker- * influenced (Claude pulls from source comments, READMEs, fetched URLs), so * a malicious `[click me](javascript:...)` link embedded in a plan must not * render as a live . Mirrors the same guard in Viewer.tsx; returns null * for blocked schemes so the caller can render the anchor text as plain * text instead of a clickable link. */ const DANGEROUS_PROTOCOL = /^\s*(javascript|data|vbscript|file)\s*:/i; function sanitizeLinkUrl(url: string): string | null { if (DANGEROUS_PROTOCOL.test(url)) return null; return url; } const InlineMarkdown: React.FC<{ text: string }> = ({ text }) => { const parts: React.ReactNode[] = []; let remaining = text; let key = 0; let previousChar = ""; while (remaining.length > 0) { // Plan-diff word markers: ... and .... These are // emitted by PlanCleanDiffView's modified-block renderer when the // diff engine populates `inlineTokens`. Content is recursively parsed // so inline formatting inside a diff token (e.g., **bold** on an // added word) still renders. let match = remaining.match(/^<(ins|del)>([\s\S]+?)<\/\1>/); if (match) { const tag = match[1] as "ins" | "del"; const className = tag === "ins" ? "plan-diff-word-added" : "plan-diff-word-removed"; if (tag === "ins") { parts.push( ); } else { parts.push( ); } remaining = remaining.slice(match[0].length); previousChar = match[0][match[0].length - 1] || previousChar; continue; } // Bold: **text** ([\s\S]+? allows matching across hard line breaks) match = remaining.match(/^\*\*([\s\S]+?)\*\*/); if (match) { parts.push( ); remaining = remaining.slice(match[0].length); previousChar = match[0][match[0].length - 1] || previousChar; continue; } // Italic: *text* or _text_ (avoid intraword underscores) match = remaining.match(/^\*([\s\S]+?)\*/); if (match) { parts.push(); remaining = remaining.slice(match[0].length); previousChar = match[0][match[0].length - 1] || previousChar; continue; } match = !/\w/.test(previousChar) ? remaining.match(/^_([^_\s](?:[\s\S]*?[^_\s])?)_(?!\w)/) : null; if (match) { parts.push(); remaining = remaining.slice(match[0].length); previousChar = match[0][match[0].length - 1] || previousChar; continue; } match = remaining.match(/^`([^`]+)`/); if (match) { parts.push( {match[1]} ); remaining = remaining.slice(match[0].length); previousChar = match[0][match[0].length - 1] || previousChar; continue; } match = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/); if (match) { // Recursively parse the anchor text so / diff tags (and // other inline markdown) inside the link render correctly instead of // showing up as literal HTML tag text. Sanitize the href: dangerous // schemes (javascript:, data:, vbscript:, file:) are rendered as // plain text instead of a live anchor to block XSS via plan content. const safeHref = sanitizeLinkUrl(match[2]); if (safeHref === null) { parts.push( ); } else { parts.push( ); } remaining = remaining.slice(match[0].length); previousChar = match[0][match[0].length - 1] || previousChar; continue; } // Hard line break: two+ trailing spaces + newline, or backslash + newline match = remaining.match(/ {2,}\n|\\\n/); if (match && match.index !== undefined) { const before = remaining.slice(0, match.index); if (before) { parts.push(); } parts.push(
); remaining = remaining.slice(match.index + match[0].length); previousChar = "\n"; continue; } // Include '<' so the loop re-enters when an / tag is next, // rather than swallowing it as plain text. const nextSpecial = remaining.slice(1).search(/[\*_`\[!<]/); if (nextSpecial === -1) { parts.push(remaining); previousChar = remaining[remaining.length - 1] || previousChar; break; } else { const plainText = remaining.slice(0, nextSpecial + 1); parts.push(plainText); remaining = remaining.slice(nextSpecial + 1); previousChar = plainText[plainText.length - 1] || previousChar; } } return <>{parts}; };