mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
fix(ui): fall back when markdown editor fails to load
This commit is contained in:
1 parent
b31470ac3d
commit
85395ce92d
2 files changed
+216
-1
No files matched your search
@@ -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(
|
||||
<MarkdownEditor
|
||||
value="Affected versions: <= v0.3.1"
|
||||
onChange={handleChange}
|
||||
placeholder="Markdown body"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
<MarkdownEditor
|
||||
value="Affected versions: <= v0.3.1"
|
||||
onChange={handleChange}
|
||||
placeholder="Add a description..."
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
|
||||
@@ -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<MarkdownEditorRef, MarkdownEditorProps>
|
||||
const { slashCommands } = useEditorAutocomplete();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const ref = useRef<MDXEditorMethods>(null);
|
||||
const fallbackTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const valueRef = useRef(editorValue);
|
||||
valueRef.current = editorValue;
|
||||
const latestValueRef = useRef(editorValue);
|
||||
@@ -460,6 +503,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
const echoIgnoreMarkdownRef = useRef<string | null>(null);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [richEditorError, setRichEditorError] = useState<string | null>(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<MarkdownEditorRef, MarkdownEditorProps>
|
||||
|
||||
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<MarkdownEditorRef, MarkdownEditorProps>
|
||||
)
|
||||
: null;
|
||||
|
||||
if (richEditorError) {
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"relative paperclip-mdxeditor-scope",
|
||||
bordered ? "rounded-md border border-border bg-transparent" : "bg-transparent",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 px-3 pt-2 text-xs text-muted-foreground">
|
||||
<p>Rich editor unavailable for this markdown. Showing raw source instead.</p>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 underline underline-offset-2 hover:text-foreground"
|
||||
onClick={() => {
|
||||
setRichEditorError(null);
|
||||
}}
|
||||
>
|
||||
Retry rich editor
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
ref={fallbackTextareaRef}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => {
|
||||
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,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
@@ -941,6 +1081,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
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",
|
||||
|
||||
Reference in new issue
Block a user