'use client';
import { useEffect, useRef, useState, useCallback } from 'react';
import { useTheme } from 'next-themes';
import { Editor, rootCtx, defaultValueCtx, editorViewCtx } from '@milkdown/core';
import { nord } from '@milkdown/theme-nord';
import { commonmark, codeBlockSchema } from '@milkdown/preset-commonmark';
import { gfm } from '@milkdown/preset-gfm';
import { listener, listenerCtx } from '@milkdown/plugin-listener';
import { Milkdown, MilkdownProvider, useEditor } from '@milkdown/react';
import { Plugin, PluginKey } from '@milkdown/prose/state';
import { Decoration, DecorationSet } from '@milkdown/prose/view';
import { $prose, $view } from '@milkdown/utils';
import type { Node as ProseMirrorNode } from '@milkdown/prose/model';
import mermaid from 'mermaid';
export type TextSelection = {
text: string;
position: { x: number; y: number };
};
export type CommentHighlight = {
id: string;
selectedText: string;
comment: string;
};
type MilkdownEditorProps = {
value: string;
onChange: (value: string) => void;
placeholder?: string;
onSelectionChange?: (selection: TextSelection | null) => void;
/** Called when selection ends (mouseup) with selected text - for showing floating button */
onSelectionEnd?: (selection: TextSelection | null) => void;
comments?: CommentHighlight[];
onCommentClick?: (id: string, position: { x: number; y: number }) => void;
};
// Minimum text length required for comment highlighting
const MIN_COMMENT_TEXT_LENGTH = 3;
// Initialize mermaid with default config
let mermaidInitialized = false;
function initMermaid(theme: 'dark' | 'light' = 'dark') {
mermaid.initialize({
startOnLoad: false,
theme: theme === 'dark' ? 'dark' : 'default',
securityLevel: 'loose',
});
mermaidInitialized = true;
}
// Unique ID counter for mermaid diagrams
let mermaidIdCounter = 0;
// Create mermaid node view for code blocks using $view
const mermaidCodeBlockView = $view(codeBlockSchema.node, () => {
return (node: ProseMirrorNode) => {
const language = node.attrs.language as string;
// If not mermaid, return null to use default rendering
if (language !== 'mermaid') {
// Create default code block rendering
const pre = document.createElement('pre');
const code = document.createElement('code');
code.textContent = node.textContent;
if (language) {
code.className = `language-${language}`;
}
pre.appendChild(code);
return {
dom: pre,
contentDOM: code,
};
}
// Track current content for comparison in update
let currentContent = node.textContent;
// Create container for mermaid diagram
const dom = document.createElement('div');
dom.className = 'mermaid-container';
// Initialize mermaid if not already done
if (!mermaidInitialized) {
initMermaid();
}
// Render the mermaid diagram
const id = `mermaid-${++mermaidIdCounter}`;
mermaid.render(id, currentContent).then(({ svg }: { svg: string }) => {
dom.innerHTML = svg;
}).catch((err: Error) => {
dom.innerHTML = `
Error rendering diagram: ${err.message}`;
});
return {
dom,
contentDOM: null,
update: (updatedNode: ProseMirrorNode) => {
if (updatedNode.type.name !== 'code_block') return false;
const updatedLang = updatedNode.attrs.language as string;
if (updatedLang !== 'mermaid') return false;
const newContent = updatedNode.textContent;
if (newContent !== currentContent) {
currentContent = newContent; // Update tracked content
const newId = `mermaid-${++mermaidIdCounter}`;
mermaid.render(newId, newContent).then(({ svg }: { svg: string }) => {
dom.innerHTML = svg;
}).catch((err: Error) => {
dom.innerHTML = `Error rendering diagram: ${err.message}`;
});
}
return true;
},
};
};
});
// Plugin key for managing comment highlights
const commentHighlightPluginKey = new PluginKey<{
deco: DecorationSet;
comments: CommentHighlight[];
}>('comment-highlight');
// Plugin key for placeholder
const placeholderPluginKey = new PluginKey('placeholder');
// Create placeholder plugin that shows text when editor is empty
function createPlaceholderPlugin(placeholder: string) {
return $prose(() => {
return new Plugin({
key: placeholderPluginKey,
props: {
decorations(state) {
const doc = state.doc;
// Check if document is empty (only has one empty paragraph)
if (doc.childCount === 1 && doc.firstChild?.type.name === 'paragraph' && doc.firstChild.content.size === 0) {
return DecorationSet.create(doc, [
Decoration.widget(1, () => {
const span = document.createElement('span');
span.className = 'editor-placeholder';
span.textContent = placeholder;
return span;
}, { side: 0 }),
]);
}
return DecorationSet.empty;
},
},
});
});
}
// Helper to get plain text content and position mapping from a document range
function getTextWithPositions(doc: Parameters[0]): { text: string; positions: number[] } {
let text = '';
const positions: number[] = []; // Maps text index to document position
doc.descendants((node, pos) => {
if (node.isText && node.text) {
for (let i = 0; i < node.text.length; i++) {
positions.push(pos + i);
text += node.text[i];
}
} else if (node.type.name === 'hardbreak') {
// Handle hardbreak as a space/newline
positions.push(-1);
text += ' ';
} else if (node.isBlock && text.length > 0 && !text.endsWith('\n')) {
// Add newline between blocks
positions.push(-1); // -1 indicates a synthetic newline
text += '\n';
}
});
return { text, positions };
}
// Normalize text for comparison (collapse whitespace, normalize newlines)
function normalizeForSearch(text: string): string {
return text
.replace(/\r\n/g, '\n')
.replace(/[\t ]+/g, ' ')
.replace(/\n+/g, '\n')
.trim()
.toLowerCase();
}
// Create decorations for comment highlights
function createCommentDecorations(
doc: Parameters[0],
comments: CommentHighlight[]
): DecorationSet {
const decorations: Decoration[] = [];
if (comments.length === 0) {
return DecorationSet.empty;
}
// Get the full text content with position mapping
const { text: fullText, positions } = getTextWithPositions(doc);
const normalizedFullText = normalizeForSearch(fullText);
comments.forEach((comment) => {
const searchText = normalizeForSearch(comment.selectedText);
if (!searchText || searchText.length < MIN_COMMENT_TEXT_LENGTH) {
return;
}
// Find the text in the normalized full document text
const normalizedIndex = normalizedFullText.indexOf(searchText);
if (normalizedIndex === -1) {
// Try with just the first line as fallback
const firstLine = normalizeForSearch(comment.selectedText.split('\n')[0]);
if (firstLine.length >= MIN_COMMENT_TEXT_LENGTH) {
const fallbackIndex = normalizedFullText.indexOf(firstLine);
if (fallbackIndex !== -1) {
createDecorationForRange(fallbackIndex, firstLine.length, comment, fullText, positions, decorations);
}
}
return;
}
createDecorationForRange(normalizedIndex, searchText.length, comment, fullText, positions, decorations);
});
return DecorationSet.create(doc, decorations);
}
// Helper to create decoration(s) for a text range, handling multi-line selections
function createDecorationForRange(
normalizedIndex: number,
length: number,
comment: CommentHighlight,
fullText: string,
positions: number[],
decorations: Decoration[]
) {
// Map normalized index back to original text index
const normalizedFullText = normalizeForSearch(fullText);
const normalizedChars = normalizedFullText.split('');
const origChars = fullText.split('');
// Map from normalized position to original position
const normToOrig: number[] = [];
let oi = 0;
for (let ni = 0; ni < normalizedChars.length; ni++) {
// Skip whitespace differences in original
while (oi < origChars.length &&
origChars[oi].toLowerCase() !== normalizedChars[ni] &&
/\s/.test(origChars[oi])) {
oi++;
}
normToOrig[ni] = oi;
oi++;
}
const startOrig = normToOrig[normalizedIndex] ?? 0;
const endOrig = normToOrig[normalizedIndex + length - 1] ?? startOrig;
// Now create decorations for each contiguous range (split by newlines/block boundaries)
let rangeStart = -1;
for (let i = startOrig; i <= endOrig && i < positions.length; i++) {
const pos = positions[i];
if (pos === -1) {
// Hit a block boundary - close current range if open
if (rangeStart !== -1) {
const fromPos = positions[rangeStart];
const toPos = positions[i - 1] + 1;
if (fromPos >= 0 && toPos > fromPos) {
decorations.push(Decoration.inline(fromPos, toPos, {
class: 'comment-highlight',
'data-comment-id': comment.id,
title: comment.comment,
}));
}
rangeStart = -1;
}
} else {
// Valid position - start range if not started
if (rangeStart === -1) {
rangeStart = i;
}
}
}
// Close final range
if (rangeStart !== -1 && rangeStart < positions.length) {
const fromPos = positions[rangeStart];
const lastValidIndex = Math.min(endOrig, positions.length - 1);
const toPos = positions[lastValidIndex] + 1;
if (fromPos >= 0 && toPos > fromPos) {
decorations.push(Decoration.inline(fromPos, toPos, {
class: 'comment-highlight',
'data-comment-id': comment.id,
title: comment.comment,
}));
}
}
}
// Create the comment highlight plugin
function createCommentHighlightPlugin(initialComments: CommentHighlight[]) {
return $prose(() => {
return new Plugin({
key: commentHighlightPluginKey,
state: {
init(_, state) {
return {
deco: createCommentDecorations(state.doc, initialComments),
comments: initialComments,
};
},
apply(tr, value, _, newState) {
// Check if comments were updated via meta
const newComments = tr.getMeta(commentHighlightPluginKey);
if (newComments !== undefined) {
return {
deco: createCommentDecorations(newState.doc, newComments),
comments: newComments,
};
}
// If document changed, remap decorations
if (tr.docChanged) {
return {
deco: createCommentDecorations(newState.doc, value.comments),
comments: value.comments,
};
}
return value;
},
},
props: {
decorations(state) {
const pluginState = commentHighlightPluginKey.getState(state);
return pluginState?.deco ?? DecorationSet.empty;
},
},
});
});
}
function MilkdownEditorInner({
value,
onChange,
placeholder = 'Start typing...',
onSelectionChange,
onSelectionEnd,
comments = [],
onCommentClick,
}: MilkdownEditorProps) {
const { resolvedTheme } = useTheme();
const wrapperRef = useRef(null);
// Capture initial value at mount - editor manages its own state after that
const initialValueRef = useRef(value);
const placeholderRef = useRef(placeholder);
const onChangeRef = useRef(onChange);
const onSelectionChangeRef = useRef(onSelectionChange);
const onSelectionEndRef = useRef(onSelectionEnd);
const commentsRef = useRef(comments);
const onCommentClickRef = useRef(onCommentClick);
// Keep refs updated
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
useEffect(() => {
onSelectionChangeRef.current = onSelectionChange;
}, [onSelectionChange]);
useEffect(() => {
onSelectionEndRef.current = onSelectionEnd;
}, [onSelectionEnd]);
useEffect(() => {
commentsRef.current = comments;
}, [comments]);
useEffect(() => {
onCommentClickRef.current = onCommentClick;
}, [onCommentClick]);
// Set up click handler for comment highlights via event delegation
useEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper) return;
const handleClick = (e: MouseEvent) => {
const target = e.target as HTMLElement;
const commentHighlight = target.closest('.comment-highlight');
if (commentHighlight && onCommentClickRef.current) {
const commentId = commentHighlight.getAttribute('data-comment-id');
if (commentId) {
onCommentClickRef.current(commentId, { x: e.clientX, y: e.clientY });
}
}
};
wrapper.addEventListener('click', handleClick);
return () => wrapper.removeEventListener('click', handleClick);
}, []);
// Handle mouseup to show floating button when selection ends
useEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper) return;
const handleMouseUp = (e: MouseEvent) => {
if (!onSelectionEndRef.current) return;
// Capture mouse position immediately
const mouseX = e.clientX;
const mouseY = e.clientY;
// Small delay to let selection finalize
setTimeout(() => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed || !selection.toString().trim()) {
onSelectionEndRef.current?.(null);
return;
}
const selectedText = selection.toString().trim();
if (selectedText.length < MIN_COMMENT_TEXT_LENGTH) {
onSelectionEndRef.current?.(null);
return;
}
// Use mouse position (where cursor is) instead of selection end
onSelectionEndRef.current?.({
text: selectedText,
position: { x: mouseX, y: mouseY },
});
}, 10);
};
const handleMouseDown = (e: MouseEvent) => {
// Don't clear if clicking on the floating button
if ((e.target as HTMLElement).closest('.floating-comment-btn')) {
return;
}
// Clear selection state when starting new selection
onSelectionEndRef.current?.(null);
};
wrapper.addEventListener('mouseup', handleMouseUp);
wrapper.addEventListener('mousedown', handleMouseDown);
return () => {
wrapper.removeEventListener('mouseup', handleMouseUp);
wrapper.removeEventListener('mousedown', handleMouseDown);
};
}, []);
// Handle Cmd+I to trigger selection popover (overrides italic formatting)
useEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper) return;
const handleKeyDown = (e: KeyboardEvent) => {
// Check for Cmd+I (Mac) or Ctrl+I (Windows/Linux)
if ((e.metaKey || e.ctrlKey) && e.key === 'i') {
// Only handle if we have a selection callback
if (!onSelectionChangeRef.current) return;
const selection = window.getSelection();
if (!selection) return;
// Prevent Milkdown's italic formatting immediately
e.preventDefault();
e.stopImmediatePropagation();
// Now check if we have valid selected text
if (selection.isCollapsed || !selection.toString().trim()) {
return;
}
const selectedText = selection.toString().trim();
if (selectedText.length < MIN_COMMENT_TEXT_LENGTH) {
return;
}
// Get position from the focus point (end of selection where cursor is)
const focusNode = selection.focusNode;
const focusOffset = selection.focusOffset;
if (focusNode) {
// Create a range at the focus point to get its position
const focusRange = document.createRange();
focusRange.setStart(focusNode, focusOffset);
focusRange.setEnd(focusNode, focusOffset);
const focusRect = focusRange.getBoundingClientRect();
// If focusRect has no width (collapsed), use its position
// Otherwise fall back to the selection's last rect
let x = focusRect.right || focusRect.left;
let y = focusRect.bottom || focusRect.top;
// If position is 0,0 (can happen), fall back to last rect of selection
if (x === 0 && y === 0) {
const range = selection.getRangeAt(0);
const rects = range.getClientRects();
const lastRect = rects.length > 0 ? rects[rects.length - 1] : range.getBoundingClientRect();
x = lastRect.right;
y = lastRect.bottom;
}
onSelectionChangeRef.current({
text: selectedText,
position: { x, y },
});
}
}
};
// Attach to wrapper element in capture phase to intercept before Milkdown
wrapper.addEventListener('keydown', handleKeyDown, true);
return () => wrapper.removeEventListener('keydown', handleKeyDown, true);
}, []);
// Create the highlight plugin once with initial comments
const highlightPlugin = useCallback(
() => createCommentHighlightPlugin(commentsRef.current),
[]
);
// Create the placeholder plugin once
const placeholderPlugin = useCallback(
() => createPlaceholderPlugin(placeholderRef.current),
[]
);
const { loading, get } = useEditor((root) =>
Editor.make()
.config((ctx) => {
ctx.set(rootCtx, root);
ctx.set(defaultValueCtx, initialValueRef.current);
ctx.get(listenerCtx).markdownUpdated((_, markdown) => {
onChangeRef.current(markdown);
});
})
.config(nord)
.use(commonmark)
.use(gfm)
.use(mermaidCodeBlockView)
.use(listener)
.use(highlightPlugin())
.use(placeholderPlugin())
);
// Update decorations when comments change
useEffect(() => {
const editor = get();
if (!editor || loading) return;
try {
// Get the editor view and dispatch a transaction to update comments
editor.action((ctx) => {
try {
const view = ctx.get(editorViewCtx);
if (view) {
const tr = view.state.tr.setMeta(commentHighlightPluginKey, comments);
view.dispatch(tr);
}
} catch {
// editorViewCtx not available yet
}
});
} catch {
// Editor might not be ready
}
}, [comments, get, loading]);
return (
{loading && (
Loading editor...
)}
);
}
export function MarkdownEditor(props: MilkdownEditorProps) {
// Generate a unique key on each mount to force fresh Milkdown instance
const [instanceId] = useState(() => Math.random().toString(36).slice(2));
return (
);
}