Fix markdown editor escaped list markers
This commit is contained in:
@@ -4,6 +4,7 @@ import remarkGfm from "remark-gfm";
|
|||||||
import { parseProjectMentionHref } from "@paperclipai/shared";
|
import { parseProjectMentionHref } from "@paperclipai/shared";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { useTheme } from "../context/ThemeContext";
|
import { useTheme } from "../context/ThemeContext";
|
||||||
|
import { normalizeMarkdownArtifacts } from "../lib/markdown";
|
||||||
|
|
||||||
interface MarkdownBodyProps {
|
interface MarkdownBodyProps {
|
||||||
children: string;
|
children: string;
|
||||||
@@ -114,6 +115,7 @@ function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: b
|
|||||||
|
|
||||||
export function MarkdownBody({ children, className }: MarkdownBodyProps) {
|
export function MarkdownBody({ children, className }: MarkdownBodyProps) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
const normalizedMarkdown = normalizeMarkdownArtifacts(children);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -154,7 +156,7 @@ export function MarkdownBody({ children, className }: MarkdownBodyProps) {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{normalizedMarkdown}
|
||||||
</Markdown>
|
</Markdown>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
} from "@mdxeditor/editor";
|
} from "@mdxeditor/editor";
|
||||||
import { buildProjectMentionHref, parseProjectMentionHref } from "@paperclipai/shared";
|
import { buildProjectMentionHref, parseProjectMentionHref } from "@paperclipai/shared";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
import { normalizeMarkdownArtifacts } from "../lib/markdown";
|
||||||
|
|
||||||
/* ---- Mention types ---- */
|
/* ---- Mention types ---- */
|
||||||
|
|
||||||
@@ -203,7 +204,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||||||
}: MarkdownEditorProps, forwardedRef) {
|
}: MarkdownEditorProps, forwardedRef) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const ref = useRef<MDXEditorMethods>(null);
|
const ref = useRef<MDXEditorMethods>(null);
|
||||||
const latestValueRef = useRef(value);
|
const latestValueRef = useRef(normalizeMarkdownArtifacts(value));
|
||||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const dragDepthRef = useRef(0);
|
const dragDepthRef = useRef(0);
|
||||||
@@ -281,9 +282,10 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||||||
}, [hasImageUpload]);
|
}, [hasImageUpload]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (value !== latestValueRef.current) {
|
const normalizedValue = normalizeMarkdownArtifacts(value);
|
||||||
ref.current?.setMarkdown(value);
|
if (normalizedValue !== latestValueRef.current) {
|
||||||
latestValueRef.current = value;
|
ref.current?.setMarkdown(normalizedValue);
|
||||||
|
latestValueRef.current = normalizedValue;
|
||||||
}
|
}
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
@@ -554,11 +556,15 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||||||
>
|
>
|
||||||
<MDXEditor
|
<MDXEditor
|
||||||
ref={ref}
|
ref={ref}
|
||||||
markdown={value}
|
markdown={normalizeMarkdownArtifacts(value)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
onChange={(next) => {
|
onChange={(next) => {
|
||||||
latestValueRef.current = next;
|
const normalizedNext = normalizeMarkdownArtifacts(next);
|
||||||
onChange(next);
|
latestValueRef.current = normalizedNext;
|
||||||
|
if (normalizedNext !== next) {
|
||||||
|
ref.current?.setMarkdown(normalizedNext);
|
||||||
|
}
|
||||||
|
onChange(normalizedNext);
|
||||||
}}
|
}}
|
||||||
onBlur={() => onBlur?.()}
|
onBlur={() => onBlur?.()}
|
||||||
className={cn("paperclip-mdxeditor", !bordered && "paperclip-mdxeditor--borderless")}
|
className={cn("paperclip-mdxeditor", !bordered && "paperclip-mdxeditor--borderless")}
|
||||||
|
|||||||
20
ui/src/lib/markdown.test.ts
Normal file
20
ui/src/lib/markdown.test.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { normalizeMarkdownArtifacts } from "./markdown";
|
||||||
|
|
||||||
|
describe("normalizeMarkdownArtifacts", () => {
|
||||||
|
it("normalizes escaped unordered list markers and space entities", () => {
|
||||||
|
const input = "Here is a list:\n\n\\* foo \n\\- bar ";
|
||||||
|
const output = normalizeMarkdownArtifacts(input);
|
||||||
|
expect(output).toBe("Here is a list:\n\n* foo \n- bar ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not rewrite escaped markers inside fenced code blocks", () => {
|
||||||
|
const input = "```md\n\\* keep literal \n\\- keep literal \n```";
|
||||||
|
expect(normalizeMarkdownArtifacts(input)).toBe(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps escaped non-list syntax intact", () => {
|
||||||
|
const input = "\\*not-a-list";
|
||||||
|
expect(normalizeMarkdownArtifacts(input)).toBe(input);
|
||||||
|
});
|
||||||
|
});
|
||||||
47
ui/src/lib/markdown.ts
Normal file
47
ui/src/lib/markdown.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
const FENCE_RE = /^\s*(`{3,}|~{3,})/;
|
||||||
|
const SPACE_ENTITY_RE = / /gi;
|
||||||
|
const ESCAPED_UNORDERED_LIST_RE = /^(\s{0,3})\\([*+-])([ \t]+)/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize markdown artifacts emitted by rich-text serialization so
|
||||||
|
* plain markdown list syntax remains usable in Paperclip editors.
|
||||||
|
*/
|
||||||
|
export function normalizeMarkdownArtifacts(markdown: string): string {
|
||||||
|
if (!markdown) return markdown;
|
||||||
|
|
||||||
|
const lines = markdown.split(/\r?\n/);
|
||||||
|
let inFence = false;
|
||||||
|
let fenceMarker: "`" | "~" | null = null;
|
||||||
|
let fenceLength = 0;
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
const normalized = lines.map((line) => {
|
||||||
|
const fenceMatch = FENCE_RE.exec(line);
|
||||||
|
if (fenceMatch) {
|
||||||
|
const marker = fenceMatch[1];
|
||||||
|
if (!inFence) {
|
||||||
|
inFence = true;
|
||||||
|
fenceMarker = marker[0] as "`" | "~";
|
||||||
|
fenceLength = marker.length;
|
||||||
|
} else if (marker[0] === fenceMarker && marker.length >= fenceLength) {
|
||||||
|
inFence = false;
|
||||||
|
fenceMarker = null;
|
||||||
|
fenceLength = 0;
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inFence) return line;
|
||||||
|
|
||||||
|
let next = line;
|
||||||
|
if (next.includes(" ")) {
|
||||||
|
next = next.replace(SPACE_ENTITY_RE, " ");
|
||||||
|
}
|
||||||
|
const unescaped = next.replace(ESCAPED_UNORDERED_LIST_RE, "$1$2$3");
|
||||||
|
if (unescaped !== line) changed = true;
|
||||||
|
return unescaped;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!changed) return markdown;
|
||||||
|
return normalized.join("\n");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user