import React, { useEffect, useRef, useCallback, useState } from 'react'; import { Editor, defaultValueCtx, editorViewCtx, rootCtx, remarkStringifyOptionsCtx, remarkPluginsCtx } from '@milkdown/kit/core'; import { commonmark, toggleStrongCommand, toggleEmphasisCommand, wrapInBlockquoteCommand, wrapInBulletListCommand, wrapInOrderedListCommand, insertHrCommand, toggleInlineCodeCommand, insertImageCommand, toggleLinkCommand } from '@milkdown/kit/preset/commonmark'; import { gfm, toggleStrikethroughCommand } from '@milkdown/kit/preset/gfm'; import { history, undoCommand, redoCommand } from '@milkdown/kit/plugin/history'; import { listener, listenerCtx } from '@milkdown/kit/plugin/listener'; import { clipboard } from '@milkdown/kit/plugin/clipboard'; import { trailing } from '@milkdown/kit/plugin/trailing'; import { indent } from '@milkdown/kit/plugin/indent'; import { cursor } from '@milkdown/kit/plugin/cursor'; import { replaceAll } from '@milkdown/kit/utils'; import { Milkdown, MilkdownProvider, useInstance, useEditor } from '@milkdown/react'; import { callCommand } from '@milkdown/kit/utils'; import type { Ctx } from '@milkdown/kit/ctx'; import type { RemarkPlugin } from '@milkdown/kit/transformer'; import type { Root, List, ListItem } from 'mdast'; import type { Plugin } from 'unified'; import { visit } from 'unist-util-visit'; import { macroPlugin } from '../../plugins/macroPlugin'; import { imageResolverPlugin } from '../../plugins/imageResolverPlugin'; // Import macros module to register all macro definitions import '../../macros'; import './MilkdownEditor.css'; import { InsertModal } from '../InsertModal'; import { normalizeMilkdownMarkdown } from '../../utils/markdownEscape'; import { useI18n } from '../../i18n'; // Remark plugin to force tight lists (no blank lines between list items) const remarkTightListsPlugin: Plugin<[Record], Root> = () => { return (tree: Root) => { visit(tree, 'list', (node) => { const listNode = node as List; // Set spread to false to make lists tight listNode.spread = false; // Also set each list item's spread to false for (const child of listNode.children) { if (child.type === 'listItem') { (child as ListItem).spread = false; } } }); }; }; // Wrap as Milkdown RemarkPlugin format const remarkTightLists: RemarkPlugin = { plugin: remarkTightListsPlugin, options: {}, }; type InsertModalMode = 'link' | 'image' | null; interface MilkdownEditorProps { content: string; onChange: (markdown: string) => void; placeholder?: string; } interface MilkdownChangePropagationInput { markdown: string; prevMarkdown: string; externalContent: string; hasUserInteracted: boolean; } export const shouldPropagateMilkdownChange = ({ markdown, prevMarkdown, externalContent, hasUserInteracted, }: MilkdownChangePropagationInput): boolean => { const normalized = normalizeMilkdownMarkdown(markdown); const prevNormalized = normalizeMilkdownMarkdown(prevMarkdown); const externalNormalized = normalizeMilkdownMarkdown(externalContent); if (normalized === prevNormalized) { return false; } if (!hasUserInteracted) { return false; } return normalized !== externalNormalized; }; interface EditorToolbarProps { onUserInteraction: () => void; } // Toolbar component that uses the editor instance const EditorToolbar: React.FC = ({ onUserInteraction }) => { const { t: tr } = useI18n(); const [loading, getEditor] = useInstance(); const [insertMode, setInsertMode] = useState(null); const [selectedText, setSelectedText] = useState(''); // eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any const runCommand = useCallback((commandKey: any, payload?: unknown) => { if (loading) return; const editor = getEditor(); if (editor) { onUserInteraction(); editor.action(callCommand(commandKey, payload)); } }, [loading, getEditor, onUserInteraction]); const insertHeading = useCallback((level: number) => { if (loading) return; const editor = getEditor(); if (!editor) return; onUserInteraction(); editor.action((ctx) => { const view = ctx.get(editorViewCtx); const { state, dispatch } = view; const { $from } = state.selection; const lineStart = $from.start(); const lineEnd = $from.end(); const lineText = state.doc.textBetween(lineStart, lineEnd); // Remove existing heading markers const cleanText = lineText.replace(/^#{1,6}\s*/, ''); const newText = `${'#'.repeat(level)} ${cleanText}`; const tr = state.tr.replaceWith( lineStart, lineEnd, state.schema.text(newText) ); dispatch(tr); }); }, [loading, getEditor, onUserInteraction]); // Get current selection text from editor const getSelectionText = useCallback(() => { const editor = getEditor(); if (!editor) return ''; let text = ''; editor.action((ctx) => { const view = ctx.get(editorViewCtx); const { state } = view; const { selection } = state; if (!selection.empty) { text = state.doc.textBetween(selection.from, selection.to); } }); return text; }, [getEditor]); const openLinkModal = useCallback(() => { onUserInteraction(); const text = getSelectionText(); setSelectedText(text); setInsertMode('link'); }, [getSelectionText, onUserInteraction]); const openImageModal = useCallback(() => { onUserInteraction(); setSelectedText(''); setInsertMode('image'); }, [onUserInteraction]); // Add keyboard shortcut listener for Ctrl/Cmd+K (link) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); openLinkModal(); } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [openLinkModal]); // Handle link insertion from modal const handleInsertLink = useCallback((url: string, text?: string) => { const editor = getEditor(); if (!editor) return; onUserInteraction(); editor.action((ctx) => { const view = ctx.get(editorViewCtx); const { state, dispatch } = view; const { selection, schema } = state; const currentSelectedText = selection.empty ? '' : state.doc.textBetween(selection.from, selection.to); const linkText = currentSelectedText || text || url; const linkUrl = url; if (selection.empty) { // No selection - create text node with link mark and insert it const linkMark = schema.marks.link.create({ href: linkUrl }); const textNode = schema.text(linkText, [linkMark]); const tr = state.tr.replaceSelectionWith(textNode, false); dispatch(tr); } else { // Has selection - toggle link mark on selection const linkMark = schema.marks.link.create({ href: linkUrl }); const tr = state.tr.addMark(selection.from, selection.to, linkMark); dispatch(tr); } }); setInsertMode(null); }, [getEditor, onUserInteraction]); // Handle image insertion from modal const handleInsertImage = useCallback((url: string, alt: string) => { onUserInteraction(); runCommand(insertImageCommand.key, { src: url, alt }); setInsertMode(null); }, [onUserInteraction, runCommand]); if (loading) return null; return ( <>
{insertMode && ( setInsertMode(null)} initialText={selectedText} /> )} ); }; // Main component with provider wrapper export const MilkdownEditor: React.FC = (props) => { return ( ); }; // Separate component to use hooks within provider const MilkdownProviderInner: React.FC = ({ content, onChange, placeholder, }) => { const { t: tr } = useI18n(); const resolvedPlaceholder = placeholder || tr('editor.placeholder'); const [loading, getEditor] = useInstance(); const lastExternalContent = useRef(content); const isInternalChange = useRef(false); const onChangeRef = useRef(onChange); const contentRef = useRef(content); const hasUserInteractedRef = useRef(false); // Keep refs updated useEffect(() => { onChangeRef.current = onChange; }, [onChange]); useEffect(() => { contentRef.current = content; }, [content]); // Initialize editor using useEditor hook useEditor((root) => { return Editor.make() .config((ctx: Ctx) => { ctx.set(rootCtx, root); ctx.set(defaultValueCtx, contentRef.current); // Configure remark-stringify to produce tight lists (no blank lines between list items) ctx.set(remarkStringifyOptionsCtx, { bullet: '-', listItemIndent: 'one', }); // Add custom remark plugin to force tight lists ctx.set(remarkPluginsCtx, [remarkTightLists]); ctx.get(listenerCtx).markdownUpdated((_ctx: Ctx, markdown: string, prevMarkdown: string) => { const shouldEmit = shouldPropagateMilkdownChange({ markdown, prevMarkdown, externalContent: contentRef.current, hasUserInteracted: hasUserInteractedRef.current, }); if (shouldEmit) { isInternalChange.current = true; onChangeRef.current(normalizeMilkdownMarkdown(markdown)); } }); }) .use(commonmark) .use(gfm) .use(imageResolverPlugin) .use(macroPlugin) .use(history) .use(listener) .use(clipboard) .use(trailing) .use(indent) .use(cursor); }, []); // Handle external content changes useEffect(() => { if (loading) return; const editor = getEditor(); if (!editor) return; if (content !== lastExternalContent.current && !isInternalChange.current) { hasUserInteractedRef.current = false; editor.action(replaceAll(content)); lastExternalContent.current = content; } else if (isInternalChange.current) { // Update ref for internal changes to keep it in sync lastExternalContent.current = content; } isInternalChange.current = false; }, [content, loading, getEditor]); const markUserInteraction = useCallback(() => { hasUserInteractedRef.current = true; }, []); return (
); }; export default MilkdownEditor;