feat: swiched to milkdown

This commit is contained in:
2026-02-11 16:01:43 +01:00
parent ac6f07b9fe
commit 11ec82d12e
12 changed files with 3045 additions and 1354 deletions

View File

@@ -0,0 +1,332 @@
.milkdown-editor {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
height: 100%;
}
/* Toolbar */
.milkdown-toolbar {
display: flex;
align-items: center;
padding: 8px 12px;
background-color: var(--vscode-sideBar-background);
border-bottom: 1px solid var(--vscode-panel-border);
flex-wrap: wrap;
gap: 4px;
flex-shrink: 0;
}
.milkdown-toolbar .toolbar-group {
display: flex;
align-items: center;
gap: 2px;
}
.milkdown-toolbar .toolbar-divider {
width: 1px;
height: 20px;
background-color: var(--vscode-panel-border);
margin: 0 8px;
}
.milkdown-toolbar button {
display: flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 28px;
padding: 4px 8px;
background-color: transparent;
border: none;
border-radius: 4px;
color: var(--vscode-foreground);
font-size: 13px;
cursor: pointer;
transition: background-color 0.15s;
}
.milkdown-toolbar button:hover:not(:disabled) {
background-color: var(--vscode-toolbar-hoverBackground);
}
.milkdown-toolbar button.is-active {
background-color: var(--vscode-button-background);
color: var(--vscode-button-foreground);
}
.milkdown-toolbar button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Editor Content */
.milkdown-content {
flex: 1;
padding: 16px;
overflow-y: auto;
display: flex;
flex-direction: column;
}
/* Milkdown Editor Styles */
.milkdown-content .milkdown {
outline: none;
flex: 1;
display: flex;
flex-direction: column;
}
.milkdown-content .milkdown .ProseMirror {
flex: 1;
outline: none;
color: var(--vscode-editor-foreground);
font-size: 15px;
line-height: 1.7;
}
.milkdown-content .milkdown .ProseMirror > * + * {
margin-top: 0.75em;
}
/* Placeholder */
.milkdown-content .milkdown .ProseMirror.ProseMirror-empty::before {
content: attr(data-placeholder);
color: var(--vscode-descriptionForeground);
pointer-events: none;
position: absolute;
}
.milkdown-content .milkdown .ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: var(--vscode-descriptionForeground);
pointer-events: none;
height: 0;
}
/* Typography */
.milkdown-content h1 {
font-size: 2em;
font-weight: 600;
margin-top: 1em;
margin-bottom: 0.5em;
color: var(--vscode-editor-foreground);
}
.milkdown-content h2 {
font-size: 1.5em;
font-weight: 600;
margin-top: 1em;
margin-bottom: 0.5em;
color: var(--vscode-editor-foreground);
}
.milkdown-content h3 {
font-size: 1.25em;
font-weight: 600;
margin-top: 1em;
margin-bottom: 0.5em;
color: var(--vscode-editor-foreground);
}
.milkdown-content h4,
.milkdown-content h5,
.milkdown-content h6 {
font-size: 1.1em;
font-weight: 600;
margin-top: 1em;
margin-bottom: 0.5em;
color: var(--vscode-editor-foreground);
}
.milkdown-content p {
margin: 0.5em 0;
}
.milkdown-content strong {
font-weight: 600;
}
.milkdown-content em {
font-style: italic;
}
.milkdown-content del,
.milkdown-content s {
text-decoration: line-through;
}
/* Links */
.milkdown-content a {
color: var(--vscode-textLink-foreground);
text-decoration: underline;
cursor: pointer;
}
.milkdown-content a:hover {
color: var(--vscode-textLink-activeForeground);
}
/* Lists */
.milkdown-content ul,
.milkdown-content ol {
padding-left: 1.5em;
margin: 0.5em 0;
}
.milkdown-content li {
margin: 0.25em 0;
}
.milkdown-content ul {
list-style-type: disc;
}
.milkdown-content ol {
list-style-type: decimal;
}
/* Task List (GFM) */
.milkdown-content li[data-task-item] {
list-style: none;
position: relative;
margin-left: -1.5em;
padding-left: 1.5em;
}
.milkdown-content li[data-task-item] input[type="checkbox"] {
position: absolute;
left: 0;
top: 0.3em;
}
/* Blockquote */
.milkdown-content blockquote {
border-left: 3px solid var(--vscode-textBlockQuote-border);
padding-left: 1em;
margin: 1em 0;
color: var(--vscode-textBlockQuote-foreground);
font-style: italic;
}
/* Code */
.milkdown-content code {
background-color: var(--vscode-textCodeBlock-background);
padding: 2px 6px;
border-radius: 3px;
font-family: var(--vscode-editor-font-family);
font-size: 0.9em;
}
.milkdown-content pre {
background-color: var(--vscode-textCodeBlock-background);
padding: 12px 16px;
border-radius: 6px;
overflow-x: auto;
margin: 1em 0;
}
.milkdown-content pre code {
padding: 0;
background: none;
font-size: 0.9em;
line-height: 1.5;
}
/* Images */
.milkdown-content img {
max-width: 100%;
height: auto;
border-radius: 6px;
margin: 1em 0;
cursor: pointer;
transition: box-shadow 0.2s;
}
.milkdown-content img:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
/* Horizontal Rule */
.milkdown-content hr {
border: none;
border-top: 1px solid var(--vscode-panel-border);
margin: 2em 0;
}
/* Tables (GFM) */
.milkdown-content table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
}
.milkdown-content th,
.milkdown-content td {
border: 1px solid var(--vscode-panel-border);
padding: 8px 12px;
text-align: left;
}
.milkdown-content th {
background-color: var(--vscode-sideBar-background);
font-weight: 600;
}
.milkdown-content tr:nth-child(even) {
background-color: rgba(255, 255, 255, 0.02);
}
/* Selection */
.milkdown-content .ProseMirror-selectednode {
outline: 2px solid var(--vscode-focusBorder);
}
/* Block handle (from block plugin) */
.milkdown-content .block-handle {
position: absolute;
left: -24px;
width: 20px;
height: 20px;
cursor: grab;
opacity: 0;
transition: opacity 0.2s;
display: flex;
align-items: center;
justify-content: center;
color: var(--vscode-descriptionForeground);
}
.milkdown-content .block-handle:hover {
opacity: 1;
}
/* Cursor styles */
.milkdown-content .ProseMirror-gapcursor {
display: none;
pointer-events: none;
position: absolute;
}
.milkdown-content .ProseMirror-gapcursor:after {
content: "";
display: block;
position: absolute;
top: -2px;
width: 20px;
border-top: 1px solid var(--vscode-editor-foreground);
animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite;
}
@keyframes ProseMirror-cursor-blink {
to {
visibility: hidden;
}
}
/* Focus ring */
.milkdown-content .ProseMirror:focus-visible {
outline: none;
}

View File

@@ -0,0 +1,253 @@
import React, { useEffect, useRef, useCallback } 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 './MilkdownEditor.css';
// Remark plugin to force tight lists (no blank lines between list items)
const remarkTightListsPlugin: Plugin<[Record<string, unknown>], 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: {},
};
interface MilkdownEditorProps {
content: string;
onChange: (markdown: string) => void;
placeholder?: string;
}
// Toolbar component that uses the editor instance
const EditorToolbar: React.FC = () => {
const [loading, getEditor] = useInstance();
// 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) {
editor.action(callCommand(commandKey, payload));
}
}, [loading, getEditor]);
const insertHeading = useCallback((level: number) => {
if (loading) return;
const editor = getEditor();
if (!editor) return;
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]);
const insertLink = useCallback(() => {
const url = window.prompt('Enter URL:');
if (!url) return;
runCommand(toggleLinkCommand.key, { href: url });
}, [runCommand]);
const insertImage = useCallback(() => {
const url = window.prompt('Enter image URL:');
if (!url) return;
const alt = window.prompt('Enter alt text:', 'Image') || 'Image';
runCommand(insertImageCommand.key, { src: url, alt });
}, [runCommand]);
if (loading) return null;
return (
<div className="milkdown-toolbar">
<div className="toolbar-group">
<button onClick={() => insertHeading(1)} title="Heading 1">H1</button>
<button onClick={() => insertHeading(2)} title="Heading 2">H2</button>
<button onClick={() => insertHeading(3)} title="Heading 3">H3</button>
</div>
<div className="toolbar-divider" />
<div className="toolbar-group">
<button onClick={() => runCommand(toggleStrongCommand.key)} title="Bold (Ctrl+B)">
<strong>B</strong>
</button>
<button onClick={() => runCommand(toggleEmphasisCommand.key)} title="Italic (Ctrl+I)">
<em>I</em>
</button>
<button onClick={() => runCommand(toggleStrikethroughCommand.key)} title="Strikethrough">
<s>S</s>
</button>
</div>
<div className="toolbar-divider" />
<div className="toolbar-group">
<button onClick={() => runCommand(wrapInBulletListCommand.key)} title="Bullet List"></button>
<button onClick={() => runCommand(wrapInOrderedListCommand.key)} title="Numbered List">1.</button>
<button onClick={() => runCommand(wrapInBlockquoteCommand.key)} title="Quote"></button>
<button onClick={() => runCommand(toggleInlineCodeCommand.key)} title="Code">{'{}'}</button>
</div>
<div className="toolbar-divider" />
<div className="toolbar-group">
<button onClick={insertLink} title="Insert Link">🔗</button>
<button onClick={insertImage} title="Insert Image">🖼</button>
<button onClick={() => runCommand(insertHrCommand.key)} title="Horizontal Rule"></button>
</div>
<div className="toolbar-divider" />
<div className="toolbar-group">
<button onClick={() => runCommand(undoCommand.key)} title="Undo (Ctrl+Z)"></button>
<button onClick={() => runCommand(redoCommand.key)} title="Redo (Ctrl+Y)"></button>
</div>
</div>
);
};
// Main component with provider wrapper
export const MilkdownEditor: React.FC<MilkdownEditorProps> = (props) => {
return (
<MilkdownProvider>
<MilkdownProviderInner {...props} />
</MilkdownProvider>
);
};
// Separate component to use hooks within provider
const MilkdownProviderInner: React.FC<MilkdownEditorProps> = ({
content,
onChange,
placeholder = 'Start writing your content...',
}) => {
const [loading, getEditor] = useInstance();
const lastExternalContent = useRef(content);
const isInternalChange = useRef(false);
const onChangeRef = useRef(onChange);
const contentRef = useRef(content);
// Store the normalized markdown after Milkdown's initial round-trip
// Only trigger onChange if the markdown differs from this baseline
const normalizedBaseline = useRef<string | null>(null);
// 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) => {
if (markdown !== prevMarkdown) {
// On first update after load, store the normalized baseline
// This captures Milkdown's round-trip formatting
if (normalizedBaseline.current === null) {
normalizedBaseline.current = markdown;
return; // Don't trigger onChange for initial normalization
}
// Only trigger onChange if content differs from the baseline
// (meaning the user actually edited something)
if (markdown !== normalizedBaseline.current) {
isInternalChange.current = true;
normalizedBaseline.current = markdown; // Update baseline
onChangeRef.current(markdown);
}
}
});
})
.use(commonmark)
.use(gfm)
.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) {
// Reset baseline so next markdownUpdated captures new normalized content
normalizedBaseline.current = null;
editor.action(replaceAll(content));
lastExternalContent.current = content;
}
isInternalChange.current = false;
}, [content, loading, getEditor]);
return (
<div className="milkdown-editor">
<EditorToolbar />
<div className="milkdown-content" data-placeholder={placeholder}>
<Milkdown />
</div>
</div>
);
};
export default MilkdownEditor;

View File

@@ -0,0 +1 @@
export { MilkdownEditor } from './MilkdownEditor';