"use client" import React, { useRef, useCallback, useState, useEffect } from "react" import dynamic from "../../embed-shims/next-dynamic" import { Loader2, Upload } from "lucide-react" import { cn } from "../../utils/cn" const MDEditor = dynamic(() => import("@uiw/react-md-editor"), { ssr: false }) const MARKDOWN_EDITOR_STYLE_ID = "ods-markdown-editor-styles" const mdEditorCSS = ` :root { --md-editor-text-color: var(--color-text-primary) !important; } body .w-md-editor { background-color: var(--color-bg) !important; border: 1px solid var(--ods-system-greys-soft-grey) !important; border-radius: 6px !important; --md-editor-text-color: var(--color-text-primary) !important; --md-editor-box-shadow-color: transparent !important; box-shadow: none !important; } body .w-md-editor-area, body .w-md-editor-input, body .w-md-editor-text, body .w-md-editor-text-pre, body .w-md-editor-text-input, body .w-md-editor-text-textarea { background-color: var(--color-bg-card) !important; color: var(--color-text-primary) !important; font-family: var(--font-family-body) !important; font-size: 18px !important; font-weight: 500 !important; line-height: 24px !important; padding: 16px !important; } body .w-md-editor-text-textarea::placeholder { color: var(--color-text-secondary) !important; font-family: var(--font-family-body) !important; font-size: 18px !important; font-weight: 500 !important; } body .w-md-editor-text .token, body .w-md-editor-text-pre .token, body .w-md-editor-text-textarea, body .w-md-editor-text-input { color: var(--color-text-primary) !important; } body .w-md-editor-text-pre *, body .w-md-editor-text-textarea *, body .w-md-editor-text-input *, body .w-md-editor-text * { color: var(--color-text-primary) !important; } body .w-md-editor-text-pre .token.title, body .w-md-editor-text-pre .token.bold, body .w-md-editor-text-pre .token.code, body .w-md-editor-text-pre .token.string, body .w-md-editor-text-pre .token.keyword { color: var(--color-text-primary) !important; } body .w-md-editor-text-textarea, body .w-md-editor-text-input, body .w-md-editor-text-pre { color: var(--color-text-primary) !important; -webkit-text-fill-color: var(--color-text-primary) !important; text-shadow: none !important; filter: brightness(1) contrast(1) !important; } body .w-md-editor { color: var(--color-text-primary) !important; } body .w-md-editor *:not(.w-md-editor-toolbar *, .w-md-editor-preview *, .custom-preview-wrapper *) { color: var(--color-text-primary) !important; -webkit-text-fill-color: var(--color-text-primary) !important; } .w-md-editor-toolbar { background-color: var(--color-bg) !important; border-bottom: 1px solid var(--color-border-default) !important; padding: 12px 16px !important; } .w-md-editor-toolbar ul li button { background: transparent !important; border: none !important; color: var(--color-text-primary) !important; padding: 8px 12px !important; border-radius: 6px !important; transition: all 0.2s ease !important; min-height: 36px !important; min-width: 36px !important; } .w-md-editor-toolbar ul li button:hover { background-color: var(--color-border-default) !important; color: var(--ods-accent) !important; } .w-md-editor-toolbar ul li button.active, .w-md-editor-toolbar ul li button[aria-pressed="true"] { background-color: var(--ods-accent) !important; color: var(--color-text-on-accent) !important; } .w-md-editor-toolbar-divider { display: none !important; } .w-md-editor-preview { background-color: var(--color-bg) !important; color: var(--color-text-primary) !important; border-left: 1px solid var(--color-border-default) !important; --color-border-default: var(--ods-system-greys-soft-grey) !important; --color-border-muted: var(--ods-system-greys-soft-grey) !important; --color-fg-muted: var(--ods-system-greys-grey) !important; } .w-md-editor-text { border-top: 1px solid var(--color-border-default) !important; } .w-md-editor-focus { border-color: var(--ods-accent) !important; } body .w-md-editor .w-md-editor-bar { width: 24px !important; height: 12px !important; margin-left: auto !important; margin-right: 8px !important; cursor: ns-resize !important; } body .w-md-editor .w-md-editor-bar svg { display: none !important; } body .w-md-editor .w-md-editor-bar::after { content: '' !important; display: block !important; width: 24px !important; height: 4px !important; border-top: 2px solid var(--color-border-default) !important; border-bottom: 2px solid var(--color-border-default) !important; margin-top: 2px !important; border-radius: 1px !important; } body .w-md-editor .w-md-editor-bar:hover::after { border-color: var(--color-text-secondary) !important; } ` function MarkdownEditorStyles() { useEffect(() => { if (document.getElementById(MARKDOWN_EDITOR_STYLE_ID)) return const style = document.createElement("style") style.id = MARKDOWN_EDITOR_STYLE_ID style.textContent = mdEditorCSS document.head.appendChild(style) return () => { style.remove() } }, []) return null } export interface MarkdownEditorProps { /** Markdown string content */ value: string /** Callback with updated markdown string */ onChange: (markdown: string) => void /** Placeholder text shown when editor is empty */ placeholder?: string /** Whether the editor is disabled */ disabled?: boolean /** Initial editor height in pixels (default: 600). User can resize via drag handle. */ height?: number /** Minimum editor height in pixels (default: 100) */ minHeight?: number /** Additional className for the wrapper */ className?: string /** * File upload handler. Return the public URL of the uploaded file. * If not provided, the upload button is hidden. */ onUploadFile?: (file: File) => Promise /** Called after a file is successfully uploaded and inserted */ onFileUploaded?: (url: string, filename: string) => void /** Custom preview renderer. Receives the markdown source string. */ renderPreview?: (source: string) => React.ReactNode } export function MarkdownEditor({ value, onChange, placeholder = "", disabled = false, height = 600, minHeight = 100, className, onUploadFile, onFileUploaded, renderPreview, }: MarkdownEditorProps) { const fileInputRef = useRef(null) const [isUploading, setIsUploading] = useState(false) const [uploadProgress, setUploadProgress] = useState("") const [defaultExtraCommands, setDefaultExtraCommands] = useState([]) useEffect(() => { import("@uiw/react-md-editor").then((mod) => { if (mod.commands?.getExtraCommands) { setDefaultExtraCommands(mod.commands.getExtraCommands()) } }) }, []) const handleChange = (val?: string) => { onChange(val || "") } const insertTextAtCursor = useCallback( (text: string) => { const textarea = document.querySelector( ".w-md-editor-text-textarea" ) as HTMLTextAreaElement if (textarea) { const start = textarea.selectionStart const end = textarea.selectionEnd const newValue = value.slice(0, start) + text + value.slice(end) onChange(newValue) } else { onChange(value + "\n" + text) } }, [value, onChange] ) const handleFileUpload = useCallback( async (file: File) => { if (!onUploadFile) return setIsUploading(true) setUploadProgress(`Uploading ${file.name}...`) try { const url = await onUploadFile(file) const isImage = file.type.startsWith("image/") const markdown = isImage ? `![${file.name}](${url})` : `[${file.name}](${url})` insertTextAtCursor(markdown) onFileUploaded?.(url, file.name) } catch (error) { console.error("File upload failed:", error) setUploadProgress("Upload failed. Please try again.") } finally { setIsUploading(false) setTimeout(() => setUploadProgress(""), 3000) } }, [onUploadFile, insertTextAtCursor, onFileUploaded] ) const handleFileInputChange = useCallback( (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (file) { handleFileUpload(file) e.target.value = "" } }, [handleFileUpload] ) const handlePaste = useCallback( (e: React.ClipboardEvent) => { if (!onUploadFile) return const items = e.clipboardData?.items if (!items) return for (const item of items) { if (item.type.startsWith("image/")) { e.preventDefault() const file = item.getAsFile() if (file) handleFileUpload(file) return } } }, [onUploadFile, handleFileUpload] ) const CustomPreview = ({ source }: { source: string }) => { if (renderPreview) { return (
{renderPreview(source)}
) } return null } const uploadCommand = onUploadFile ? { name: "upload", keyCommand: "upload", buttonProps: { "aria-label": "Upload file", title: "Upload file" }, icon: isUploading ? ( ) : ( ), execute: () => { fileInputRef.current?.click() }, } : null const extraCommands = uploadCommand ? [...defaultExtraCommands, uploadCommand] : defaultExtraCommands const wrapperRef = useRef(null) const isDraggingRef = useRef(false) const mouseYRef = useRef(0) const rafRef = useRef(0) const scrollParentRef = useRef(window) const EDGE_ZONE = 60 const MAX_SCROLL_SPEED = 15 const findScrollParent = useCallback((el: HTMLElement | null): HTMLElement | Window => { let node = el?.parentElement while (node && node !== document.documentElement) { const { overflowY } = window.getComputedStyle(node) if ((overflowY === "auto" || overflowY === "scroll") && node.scrollHeight > node.clientHeight) { return node } node = node.parentElement } return window }, []) const scrollLoop = useCallback(() => { if (!isDraggingRef.current) return const parent = scrollParentRef.current const isWindow = parent === window const viewportBottom = isWindow ? window.innerHeight : (parent as HTMLElement).getBoundingClientRect().bottom const distFromBottom = viewportBottom - mouseYRef.current if (distFromBottom < EDGE_ZONE) { const speed = Math.ceil(MAX_SCROLL_SPEED * (1 - distFromBottom / EDGE_ZONE)) if (isWindow) { window.scrollBy(0, speed) } else { (parent as HTMLElement).scrollTop += speed } } rafRef.current = requestAnimationFrame(scrollLoop) }, []) useEffect(() => { const wrapper = wrapperRef.current if (!wrapper) return const onMouseMove = (e: MouseEvent) => { mouseYRef.current = e.clientY } const onMouseDown = (e: MouseEvent) => { if ((e.target as HTMLElement).closest(".w-md-editor-bar")) { isDraggingRef.current = true mouseYRef.current = e.clientY scrollParentRef.current = findScrollParent(wrapper) window.addEventListener("mousemove", onMouseMove) rafRef.current = requestAnimationFrame(scrollLoop) } } const onMouseUp = () => { if (isDraggingRef.current) { isDraggingRef.current = false window.removeEventListener("mousemove", onMouseMove) cancelAnimationFrame(rafRef.current) } } wrapper.addEventListener("mousedown", onMouseDown) window.addEventListener("mouseup", onMouseUp) return () => { wrapper.removeEventListener("mousedown", onMouseDown) window.removeEventListener("mouseup", onMouseUp) window.removeEventListener("mousemove", onMouseMove) cancelAnimationFrame(rafRef.current) } }, [scrollLoop, findScrollParent]) return (
{onUploadFile && ( )} {!MDEditor && (
)} {MDEditor && ( { return }, } : undefined } textareaProps={{ placeholder, disabled, style: { fontFamily: 'var(--font-family-body)', fontSize: "18px", fontWeight: 500, lineHeight: "24px", color: "var(--color-text-primary)", backgroundColor: disabled ? "var(--color-bg)" : "var(--color-bg-card)", }, }} data-color-mode="dark" /> )} {uploadProgress && (

{uploadProgress}

)}
) }