diff --git a/ui/src/components/MarkdownEditor.test.tsx b/ui/src/components/MarkdownEditor.test.tsx index a4c555b4bfa..cd08048c77b 100644 --- a/ui/src/components/MarkdownEditor.test.tsx +++ b/ui/src/components/MarkdownEditor.test.tsx @@ -16,6 +16,8 @@ import { const mdxEditorMockState = vi.hoisted(() => ({ emitMountEmptyReset: false, + emitMountParseError: false, + emitMountSilentEmptyState: false, markdownValues: [] as string[], })); @@ -37,11 +39,13 @@ vi.mock("@mdxeditor/editor", async () => { markdown, placeholder, onChange, + onError, className, }: { markdown: string; placeholder?: string; onChange?: (value: string) => void; + onError?: (error: unknown) => void; className?: string; }, forwardedRef: React.ForwardedRef<{ setMarkdown: (value: string) => void; focus: () => void } | null>, @@ -66,6 +70,16 @@ vi.mock("@mdxeditor/editor", async () => { setContent(""); onChange?.(""); } + if (mdxEditorMockState.emitMountSilentEmptyState) { + setContent(""); + } + if (mdxEditorMockState.emitMountParseError) { + setContent(""); + onError?.({ + error: "Unsupported markdown syntax", + source: markdown, + }); + } }, 0); return () => { window.clearTimeout(timer); @@ -148,6 +162,8 @@ describe("MarkdownEditor", () => { Range.prototype.getBoundingClientRect = originalRangeRect; vi.clearAllMocks(); mdxEditorMockState.emitMountEmptyReset = false; + mdxEditorMockState.emitMountParseError = false; + mdxEditorMockState.emitMountSilentEmptyState = false; mdxEditorMockState.markdownValues = []; }); @@ -230,6 +246,62 @@ describe("MarkdownEditor", () => { }); }); + it("falls back to a raw textarea when the rich parser rejects the markdown", async () => { + mdxEditorMockState.emitMountParseError = true; + const handleChange = vi.fn(); + const root = createRoot(container); + + await act(async () => { + root.render( + , + ); + }); + + await flush(); + + const textarea = container.querySelector("textarea"); + expect(textarea).not.toBeNull(); + expect(textarea?.value).toBe("Affected versions: <= v0.3.1"); + expect(container.textContent).toContain("Rich editor unavailable for this markdown"); + expect(handleChange).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + }); + }); + + it("falls back to a raw textarea when the rich editor mounts into the placeholder without callbacks", async () => { + mdxEditorMockState.emitMountSilentEmptyState = true; + const handleChange = vi.fn(); + const root = createRoot(container); + + await act(async () => { + root.render( + , + ); + }); + + await flush(); + + const textarea = container.querySelector("textarea"); + expect(textarea).not.toBeNull(); + expect(textarea?.value).toBe("Affected versions: <= v0.3.1"); + expect(container.textContent).toContain("Rich editor unavailable for this markdown"); + expect(handleChange).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + }); + }); + it("anchors the mention menu inside the visual viewport when mobile offsets are present", () => { expect( computeMentionMenuPosition( diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 5b2c9a5dc1b..73b7951f83e 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -106,6 +106,48 @@ function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } +function hasMeaningfulEditorContent(node: Node | null): boolean { + if (!node) return false; + if (node.nodeType === Node.TEXT_NODE) { + return (node.textContent ?? "").trim().length > 0; + } + if (node.nodeType !== Node.ELEMENT_NODE) { + return false; + } + + const element = node as HTMLElement; + if (["IMG", "HR", "TABLE", "VIDEO", "IFRAME"].includes(element.tagName)) { + return true; + } + + return Array.from(element.childNodes).some((child) => hasMeaningfulEditorContent(child)); +} + +function isRichEditorDomEmpty( + editable: HTMLElement, + expectedValue: string, + placeholder?: string, +): boolean { + const expectedText = expectedValue.trim(); + if (!expectedText) return false; + + const visibleText = (editable.textContent ?? "").trim(); + if (visibleText.length === 0) { + return !Array.from(editable.childNodes).some((child) => hasMeaningfulEditorContent(child)); + } + + const normalizedPlaceholder = placeholder?.trim(); + if ( + normalizedPlaceholder + && visibleText === normalizedPlaceholder + && expectedText !== normalizedPlaceholder + ) { + return true; + } + + return false; +} + function isSafeMarkdownLinkUrl(url: string): boolean { const trimmed = url.trim(); if (!trimmed) return true; @@ -448,6 +490,7 @@ export const MarkdownEditor = forwardRef const { slashCommands } = useEditorAutocomplete(); const containerRef = useRef(null); const ref = useRef(null); + const fallbackTextareaRef = useRef(null); const valueRef = useRef(editorValue); valueRef.current = editorValue; const latestValueRef = useRef(editorValue); @@ -460,6 +503,7 @@ export const MarkdownEditor = forwardRef const echoIgnoreMarkdownRef = useRef(null); const [uploadError, setUploadError] = useState(null); const [isDragOver, setIsDragOver] = useState(false); + const [richEditorError, setRichEditorError] = useState(null); const dragDepthRef = useRef(0); // Stable ref for imageUploadHandler so plugins don't recreate on every render @@ -520,9 +564,59 @@ export const MarkdownEditor = forwardRef useImperativeHandle(forwardedRef, () => ({ focus: () => { + if (richEditorError) { + fallbackTextareaRef.current?.focus(); + return; + } ref.current?.focus(undefined, { defaultSelection: "rootEnd" }); }, - }), []); + }), [richEditorError]); + + const autoSizeFallbackTextarea = useCallback((element: HTMLTextAreaElement | null) => { + if (!element) return; + element.style.height = "auto"; + element.style.height = `${element.scrollHeight}px`; + }, []); + + useEffect(() => { + if (!richEditorError) return; + autoSizeFallbackTextarea(fallbackTextareaRef.current); + }, [autoSizeFallbackTextarea, richEditorError, value]); + + useEffect(() => { + if (richEditorError || editorValue.trim().length === 0) return; + const container = containerRef.current; + if (!container) return; + + let timeoutId = 0; + const scheduleCheck = () => { + window.clearTimeout(timeoutId); + timeoutId = window.setTimeout(() => { + const editable = container.querySelector('[contenteditable="true"]'); + if (!(editable instanceof HTMLElement)) return; + const activeElement = document.activeElement; + if (activeElement === editable || editable.contains(activeElement)) return; + if (isRichEditorDomEmpty(editable, editorValue, placeholder)) { + setRichEditorError("Rich editor failed to load content"); + } + }, 0); + }; + + scheduleCheck(); + const observer = new MutationObserver(() => { + scheduleCheck(); + }); + observer.observe(container, { + subtree: true, + childList: true, + characterData: true, + }); + + return () => { + window.clearTimeout(timeoutId); + observer.disconnect(); + }; + }, [editorValue, placeholder, richEditorError]); // Whether the image plugin should be included (boolean is stable across renders // as long as the handler presence doesn't toggle) @@ -809,6 +903,52 @@ export const MarkdownEditor = forwardRef ) : null; + if (richEditorError) { + return ( + + + Rich editor unavailable for this markdown. Showing raw source instead. + { + setRichEditorError(null); + }} + > + Retry rich editor + + + { + onChange(event.target.value); + autoSizeFallbackTextarea(event.target); + }} + onBlur={() => onBlur?.()} + onKeyDown={(event) => { + if (onSubmit && event.key === "Enter" && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + onSubmit(); + } + }} + className={cn( + "min-h-[12rem] w-full resize-none bg-transparent px-3 pb-3 pt-2 font-mono text-sm leading-6 outline-none", + contentClassName, + )} + /> + + ); + } + return ( onChange(next); }} onBlur={() => onBlur?.()} + onError={(payload) => { + setRichEditorError(payload.error); + }} className={cn("paperclip-mdxeditor", !bordered && "paperclip-mdxeditor--borderless")} contentEditableClassName={cn( "paperclip-mdxeditor-content focus:outline-none [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:list-item",
Rich editor unavailable for this markdown. Showing raw source instead.