feat: swiched to milkdown
This commit is contained in:
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"chat.tools.terminal.autoApprove": {
|
"chat.tools.terminal.autoApprove": {
|
||||||
"npx vitest": true
|
"npx vitest": true,
|
||||||
|
"npx tsc": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
3118
package-lock.json
generated
3118
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
@@ -54,14 +54,17 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/dom": "^1.7.5",
|
"@floating-ui/dom": "^1.7.5",
|
||||||
"@libsql/client": "^0.4.0",
|
"@libsql/client": "^0.4.0",
|
||||||
|
"@milkdown/kit": "^7.18.0",
|
||||||
|
"@milkdown/plugin-block": "^7.18.0",
|
||||||
|
"@milkdown/plugin-clipboard": "^7.18.0",
|
||||||
|
"@milkdown/plugin-cursor": "^7.18.0",
|
||||||
|
"@milkdown/plugin-history": "^7.18.0",
|
||||||
|
"@milkdown/plugin-indent": "^7.18.0",
|
||||||
|
"@milkdown/plugin-listener": "^7.18.0",
|
||||||
|
"@milkdown/plugin-trailing": "^7.18.0",
|
||||||
|
"@milkdown/react": "^7.18.0",
|
||||||
|
"@milkdown/theme-nord": "^7.18.0",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@tiptap/extension-image": "^3.19.0",
|
|
||||||
"@tiptap/extension-link": "^3.19.0",
|
|
||||||
"@tiptap/extension-placeholder": "^3.19.0",
|
|
||||||
"@tiptap/extension-underline": "^3.19.0",
|
|
||||||
"@tiptap/pm": "^3.19.0",
|
|
||||||
"@tiptap/react": "^3.19.0",
|
|
||||||
"@tiptap/starter-kit": "^3.19.0",
|
|
||||||
"chokidar": "^5.0.0",
|
"chokidar": "^5.0.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "^0.29.0",
|
"drizzle-orm": "^0.29.0",
|
||||||
@@ -75,7 +78,6 @@
|
|||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"snowball-stemmers": "^0.6.0",
|
"snowball-stemmers": "^0.6.0",
|
||||||
"tiptap-markdown": "^0.9.0",
|
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"zustand": "^4.4.7"
|
"zustand": "^4.4.7"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
|||||||
import MonacoEditor from '@monaco-editor/react';
|
import MonacoEditor from '@monaco-editor/react';
|
||||||
import { useAppStore, PostData, EditorMode, MediaData } from '../../store';
|
import { useAppStore, PostData, EditorMode, MediaData } from '../../store';
|
||||||
import { showToast } from '../Toast';
|
import { showToast } from '../Toast';
|
||||||
import { WysiwygEditor } from '../WysiwygEditor';
|
import { MilkdownEditor } from '../MilkdownEditor';
|
||||||
import { Lightbox, useMarkdownImages } from '../Lightbox';
|
import { Lightbox, useMarkdownImages } from '../Lightbox';
|
||||||
import { PostLinks } from '../PostLinks';
|
import { PostLinks } from '../PostLinks';
|
||||||
import { ErrorModal } from '../ErrorModal';
|
import { ErrorModal } from '../ErrorModal';
|
||||||
@@ -579,7 +579,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editorMode === 'wysiwyg' && (
|
{editorMode === 'wysiwyg' && (
|
||||||
<WysiwygEditor
|
<MilkdownEditor
|
||||||
content={content}
|
content={content}
|
||||||
onChange={setContent}
|
onChange={setContent}
|
||||||
placeholder="Start writing..."
|
placeholder="Start writing..."
|
||||||
|
|||||||
332
src/renderer/components/MilkdownEditor/MilkdownEditor.css
Normal file
332
src/renderer/components/MilkdownEditor/MilkdownEditor.css
Normal 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;
|
||||||
|
}
|
||||||
253
src/renderer/components/MilkdownEditor/MilkdownEditor.tsx
Normal file
253
src/renderer/components/MilkdownEditor/MilkdownEditor.tsx
Normal 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;
|
||||||
1
src/renderer/components/MilkdownEditor/index.ts
Normal file
1
src/renderer/components/MilkdownEditor/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { MilkdownEditor } from './MilkdownEditor';
|
||||||
@@ -31,6 +31,7 @@
|
|||||||
|
|
||||||
/* Resizer handle */
|
/* Resizer handle */
|
||||||
.resizer {
|
.resizer {
|
||||||
|
position: relative;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
transition: background-color 0.15s;
|
transition: background-color 0.15s;
|
||||||
@@ -52,7 +53,7 @@
|
|||||||
background-color: var(--vscode-sash-hoverBorder, #0078d4);
|
background-color: var(--vscode-sash-hoverBorder, #0078d4);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Double-click to reset */
|
/* Extended hit area for easier grabbing */
|
||||||
.resizer::after {
|
.resizer::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -61,13 +62,15 @@
|
|||||||
.resizer.horizontal::after {
|
.resizer.horizontal::after {
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: -2px;
|
left: -4px;
|
||||||
right: -2px;
|
right: -4px;
|
||||||
|
width: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resizer.vertical::after {
|
.resizer.vertical::after {
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
top: -2px;
|
top: -4px;
|
||||||
bottom: -2px;
|
bottom: -4px;
|
||||||
|
height: 12px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,296 +0,0 @@
|
|||||||
.wysiwyg-editor {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex: 1;
|
|
||||||
background-color: var(--vscode-input-background);
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Toolbar */
|
|
||||||
.wysiwyg-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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-group {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-divider {
|
|
||||||
width: 1px;
|
|
||||||
height: 20px;
|
|
||||||
background-color: var(--vscode-panel-border);
|
|
||||||
margin: 0 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wysiwyg-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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wysiwyg-toolbar button:hover:not(:disabled) {
|
|
||||||
background-color: var(--vscode-toolbar-hoverBackground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wysiwyg-toolbar button.is-active {
|
|
||||||
background-color: var(--vscode-button-background);
|
|
||||||
color: var(--vscode-button-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wysiwyg-toolbar button:disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Editor Content */
|
|
||||||
.wysiwyg-content {
|
|
||||||
flex: 1;
|
|
||||||
padding: 16px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wysiwyg-content .ProseMirror {
|
|
||||||
outline: none;
|
|
||||||
min-height: 100%;
|
|
||||||
color: var(--vscode-editor-foreground);
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wysiwyg-content .ProseMirror > * + * {
|
|
||||||
margin-top: 0.75em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Placeholder */
|
|
||||||
.wysiwyg-content .ProseMirror p.is-editor-empty:first-child::before {
|
|
||||||
content: attr(data-placeholder);
|
|
||||||
float: left;
|
|
||||||
color: var(--vscode-descriptionForeground);
|
|
||||||
pointer-events: none;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Typography */
|
|
||||||
.wysiwyg-content h1 {
|
|
||||||
font-size: 2em;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-top: 1em;
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
color: var(--vscode-editor-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wysiwyg-content h2 {
|
|
||||||
font-size: 1.5em;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-top: 1em;
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
color: var(--vscode-editor-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wysiwyg-content h3 {
|
|
||||||
font-size: 1.25em;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-top: 1em;
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
color: var(--vscode-editor-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wysiwyg-content p {
|
|
||||||
margin: 0.5em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wysiwyg-content strong {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wysiwyg-content em {
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wysiwyg-content u {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wysiwyg-content s {
|
|
||||||
text-decoration: line-through;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Links */
|
|
||||||
.wysiwyg-content a,
|
|
||||||
.wysiwyg-content .editor-link {
|
|
||||||
color: var(--vscode-textLink-foreground);
|
|
||||||
text-decoration: underline;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wysiwyg-content a:hover {
|
|
||||||
color: var(--vscode-textLink-activeForeground);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Lists */
|
|
||||||
.wysiwyg-content ul,
|
|
||||||
.wysiwyg-content ol {
|
|
||||||
padding-left: 1.5em;
|
|
||||||
margin: 0.5em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wysiwyg-content li {
|
|
||||||
margin: 0.25em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wysiwyg-content ul {
|
|
||||||
list-style-type: disc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wysiwyg-content ol {
|
|
||||||
list-style-type: decimal;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Blockquote */
|
|
||||||
.wysiwyg-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 */
|
|
||||||
.wysiwyg-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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wysiwyg-content pre {
|
|
||||||
background-color: var(--vscode-textCodeBlock-background);
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-radius: 6px;
|
|
||||||
overflow-x: auto;
|
|
||||||
margin: 1em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wysiwyg-content pre code {
|
|
||||||
padding: 0;
|
|
||||||
background: none;
|
|
||||||
font-size: 0.9em;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Images */
|
|
||||||
.wysiwyg-content img,
|
|
||||||
.wysiwyg-content .editor-image {
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin: 1em 0;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: box-shadow 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wysiwyg-content img:hover {
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Horizontal Rule */
|
|
||||||
.wysiwyg-content hr {
|
|
||||||
border: none;
|
|
||||||
border-top: 1px solid var(--vscode-panel-border);
|
|
||||||
margin: 2em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bubble Menu */
|
|
||||||
.bubble-menu {
|
|
||||||
display: flex;
|
|
||||||
background-color: var(--vscode-editorWidget-background);
|
|
||||||
border: 1px solid var(--vscode-editorWidget-border);
|
|
||||||
border-radius: 6px;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
|
||||||
padding: 4px;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble-menu 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble-menu button:hover {
|
|
||||||
background-color: var(--vscode-toolbar-hoverBackground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble-menu button.is-active {
|
|
||||||
background-color: var(--vscode-button-background);
|
|
||||||
color: var(--vscode-button-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble-menu .divider {
|
|
||||||
width: 1px;
|
|
||||||
background-color: var(--vscode-panel-border);
|
|
||||||
margin: 2px 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Floating Menu */
|
|
||||||
.floating-menu {
|
|
||||||
display: flex;
|
|
||||||
background-color: var(--vscode-editorWidget-background);
|
|
||||||
border: 1px solid var(--vscode-editorWidget-border);
|
|
||||||
border-radius: 6px;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
|
||||||
padding: 4px;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.floating-menu button {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 6px 10px;
|
|
||||||
background-color: transparent;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: var(--vscode-foreground);
|
|
||||||
font-size: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.floating-menu button:hover {
|
|
||||||
background-color: var(--vscode-toolbar-hoverBackground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.floating-menu button.is-active {
|
|
||||||
background-color: var(--vscode-button-background);
|
|
||||||
color: var(--vscode-button-foreground);
|
|
||||||
}
|
|
||||||
@@ -1,358 +0,0 @@
|
|||||||
import React, { useEffect, useCallback, useRef } from 'react';
|
|
||||||
import { useEditor, EditorContent } from '@tiptap/react';
|
|
||||||
import { BubbleMenu, FloatingMenu } from '@tiptap/react/menus';
|
|
||||||
import StarterKit from '@tiptap/starter-kit';
|
|
||||||
import Image from '@tiptap/extension-image';
|
|
||||||
import Placeholder from '@tiptap/extension-placeholder';
|
|
||||||
import Link from '@tiptap/extension-link';
|
|
||||||
import Underline from '@tiptap/extension-underline';
|
|
||||||
import { Markdown } from 'tiptap-markdown';
|
|
||||||
import './WysiwygEditor.css';
|
|
||||||
|
|
||||||
// Type for tiptap-markdown extension storage (not exported by the package)
|
|
||||||
interface MarkdownStorage {
|
|
||||||
markdown: {
|
|
||||||
getMarkdown: () => string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WysiwygEditorProps {
|
|
||||||
content: string;
|
|
||||||
onChange: (markdown: string) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const WysiwygEditor: React.FC<WysiwygEditorProps> = ({
|
|
||||||
content,
|
|
||||||
onChange,
|
|
||||||
placeholder = 'Start writing your content...',
|
|
||||||
}) => {
|
|
||||||
// Track if we're updating from internal changes vs external prop changes
|
|
||||||
const isInternalChange = useRef(false);
|
|
||||||
// Track if we're in the middle of a programmatic content sync (shouldn't trigger onChange)
|
|
||||||
const isSyncingContent = useRef(false);
|
|
||||||
const lastExternalContent = useRef(content);
|
|
||||||
|
|
||||||
const editor = useEditor({
|
|
||||||
extensions: [
|
|
||||||
StarterKit.configure({
|
|
||||||
heading: {
|
|
||||||
levels: [1, 2, 3, 4, 5, 6],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
Link.configure({
|
|
||||||
openOnClick: false,
|
|
||||||
HTMLAttributes: {
|
|
||||||
class: 'editor-link',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
Underline,
|
|
||||||
Image.configure({
|
|
||||||
HTMLAttributes: {
|
|
||||||
class: 'editor-image',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
Placeholder.configure({
|
|
||||||
placeholder,
|
|
||||||
}),
|
|
||||||
Markdown.configure({
|
|
||||||
html: false,
|
|
||||||
bulletListMarker: '-',
|
|
||||||
transformPastedText: true,
|
|
||||||
transformCopiedText: true,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
content,
|
|
||||||
onUpdate: ({ editor }) => {
|
|
||||||
// Don't call onChange during programmatic content sync
|
|
||||||
if (isSyncingContent.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
isInternalChange.current = true;
|
|
||||||
const markdown = (editor.storage as unknown as MarkdownStorage).markdown.getMarkdown();
|
|
||||||
onChange(markdown);
|
|
||||||
},
|
|
||||||
editable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sync content from external changes only (e.g., post switch)
|
|
||||||
useEffect(() => {
|
|
||||||
if (editor && content !== lastExternalContent.current) {
|
|
||||||
// This is an external change (e.g., switching posts)
|
|
||||||
if (!isInternalChange.current) {
|
|
||||||
// Mark that we're syncing to prevent onChange from firing
|
|
||||||
isSyncingContent.current = true;
|
|
||||||
editor.commands.setContent(content);
|
|
||||||
// Reset sync flag after a microtask to ensure onUpdate has fired
|
|
||||||
Promise.resolve().then(() => {
|
|
||||||
isSyncingContent.current = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
lastExternalContent.current = content;
|
|
||||||
isInternalChange.current = false;
|
|
||||||
}
|
|
||||||
}, [content, editor]);
|
|
||||||
|
|
||||||
const addImage = useCallback(() => {
|
|
||||||
const url = window.prompt('Enter image URL:');
|
|
||||||
if (url && editor) {
|
|
||||||
editor.chain().focus().setImage({ src: url }).run();
|
|
||||||
}
|
|
||||||
}, [editor]);
|
|
||||||
|
|
||||||
const setLink = useCallback(() => {
|
|
||||||
if (!editor) return;
|
|
||||||
|
|
||||||
const previousUrl = editor.getAttributes('link').href;
|
|
||||||
const url = window.prompt('Enter URL:', previousUrl);
|
|
||||||
|
|
||||||
if (url === null) return;
|
|
||||||
|
|
||||||
if (url === '') {
|
|
||||||
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
|
||||||
}, [editor]);
|
|
||||||
|
|
||||||
if (!editor) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="wysiwyg-editor">
|
|
||||||
{/* Bubble menu appears when text is selected */}
|
|
||||||
{editor && (
|
|
||||||
<BubbleMenu className="bubble-menu" editor={editor}>
|
|
||||||
<button
|
|
||||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
|
||||||
className={editor.isActive('bold') ? 'is-active' : ''}
|
|
||||||
title="Bold"
|
|
||||||
>
|
|
||||||
<strong>B</strong>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
|
||||||
className={editor.isActive('italic') ? 'is-active' : ''}
|
|
||||||
title="Italic"
|
|
||||||
>
|
|
||||||
<em>I</em>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
|
||||||
className={editor.isActive('underline') ? 'is-active' : ''}
|
|
||||||
title="Underline"
|
|
||||||
>
|
|
||||||
<u>U</u>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
|
||||||
className={editor.isActive('strike') ? 'is-active' : ''}
|
|
||||||
title="Strikethrough"
|
|
||||||
>
|
|
||||||
<s>S</s>
|
|
||||||
</button>
|
|
||||||
<div className="divider" />
|
|
||||||
<button onClick={setLink} className={editor.isActive('link') ? 'is-active' : ''} title="Link">
|
|
||||||
🔗
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => editor.chain().focus().toggleCode().run()}
|
|
||||||
className={editor.isActive('code') ? 'is-active' : ''}
|
|
||||||
title="Code"
|
|
||||||
>
|
|
||||||
{'</>'}
|
|
||||||
</button>
|
|
||||||
</BubbleMenu>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Floating menu appears on empty lines, but only when editor has content */}
|
|
||||||
{editor && (
|
|
||||||
<FloatingMenu
|
|
||||||
className="floating-menu"
|
|
||||||
editor={editor}
|
|
||||||
shouldShow={({ editor }) => {
|
|
||||||
// Only show floating menu if editor has real content (not just empty paragraph)
|
|
||||||
const text = editor.state.doc.textContent;
|
|
||||||
if (!text || text.trim().length === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Also check if we're on an empty line (default behavior)
|
|
||||||
const { $from } = editor.state.selection;
|
|
||||||
const isEmptyLine = $from.parent.content.size === 0;
|
|
||||||
return isEmptyLine;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
|
||||||
className={editor.isActive('heading', { level: 1 }) ? 'is-active' : ''}
|
|
||||||
>
|
|
||||||
H1
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
|
||||||
className={editor.isActive('heading', { level: 2 }) ? 'is-active' : ''}
|
|
||||||
>
|
|
||||||
H2
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
|
||||||
className={editor.isActive('heading', { level: 3 }) ? 'is-active' : ''}
|
|
||||||
>
|
|
||||||
H3
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
|
||||||
className={editor.isActive('bulletList') ? 'is-active' : ''}
|
|
||||||
>
|
|
||||||
• List
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
|
||||||
className={editor.isActive('blockquote') ? 'is-active' : ''}
|
|
||||||
>
|
|
||||||
❝ Quote
|
|
||||||
</button>
|
|
||||||
<button onClick={addImage}>
|
|
||||||
🖼 Image
|
|
||||||
</button>
|
|
||||||
</FloatingMenu>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Toolbar */}
|
|
||||||
<div className="wysiwyg-toolbar">
|
|
||||||
<div className="toolbar-group">
|
|
||||||
<button
|
|
||||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
|
||||||
className={editor.isActive('heading', { level: 1 }) ? 'is-active' : ''}
|
|
||||||
title="Heading 1"
|
|
||||||
>
|
|
||||||
H1
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
|
||||||
className={editor.isActive('heading', { level: 2 }) ? 'is-active' : ''}
|
|
||||||
title="Heading 2"
|
|
||||||
>
|
|
||||||
H2
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
|
||||||
className={editor.isActive('heading', { level: 3 }) ? 'is-active' : ''}
|
|
||||||
title="Heading 3"
|
|
||||||
>
|
|
||||||
H3
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="toolbar-divider" />
|
|
||||||
|
|
||||||
<div className="toolbar-group">
|
|
||||||
<button
|
|
||||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
|
||||||
className={editor.isActive('bold') ? 'is-active' : ''}
|
|
||||||
title="Bold (Ctrl+B)"
|
|
||||||
>
|
|
||||||
<strong>B</strong>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
|
||||||
className={editor.isActive('italic') ? 'is-active' : ''}
|
|
||||||
title="Italic (Ctrl+I)"
|
|
||||||
>
|
|
||||||
<em>I</em>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
|
||||||
className={editor.isActive('underline') ? 'is-active' : ''}
|
|
||||||
title="Underline (Ctrl+U)"
|
|
||||||
>
|
|
||||||
<u>U</u>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
|
||||||
className={editor.isActive('strike') ? 'is-active' : ''}
|
|
||||||
title="Strikethrough"
|
|
||||||
>
|
|
||||||
<s>S</s>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="toolbar-divider" />
|
|
||||||
|
|
||||||
<div className="toolbar-group">
|
|
||||||
<button
|
|
||||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
|
||||||
className={editor.isActive('bulletList') ? 'is-active' : ''}
|
|
||||||
title="Bullet List"
|
|
||||||
>
|
|
||||||
•
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
|
||||||
className={editor.isActive('orderedList') ? 'is-active' : ''}
|
|
||||||
title="Numbered List"
|
|
||||||
>
|
|
||||||
1.
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
|
||||||
className={editor.isActive('blockquote') ? 'is-active' : ''}
|
|
||||||
title="Quote"
|
|
||||||
>
|
|
||||||
❝
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
|
||||||
className={editor.isActive('codeBlock') ? 'is-active' : ''}
|
|
||||||
title="Code Block"
|
|
||||||
>
|
|
||||||
{'{}'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="toolbar-divider" />
|
|
||||||
|
|
||||||
<div className="toolbar-group">
|
|
||||||
<button onClick={setLink} className={editor.isActive('link') ? 'is-active' : ''} title="Insert Link">
|
|
||||||
🔗
|
|
||||||
</button>
|
|
||||||
<button onClick={addImage} title="Insert Image">
|
|
||||||
🖼
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => editor.chain().focus().setHorizontalRule().run()}
|
|
||||||
title="Horizontal Rule"
|
|
||||||
>
|
|
||||||
―
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="toolbar-divider" />
|
|
||||||
|
|
||||||
<div className="toolbar-group">
|
|
||||||
<button
|
|
||||||
onClick={() => editor.chain().focus().undo().run()}
|
|
||||||
disabled={!editor.can().undo()}
|
|
||||||
title="Undo (Ctrl+Z)"
|
|
||||||
>
|
|
||||||
↶
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => editor.chain().focus().redo().run()}
|
|
||||||
disabled={!editor.can().redo()}
|
|
||||||
title="Redo (Ctrl+Y)"
|
|
||||||
>
|
|
||||||
↷
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Editor Content */}
|
|
||||||
<EditorContent className="wysiwyg-content" editor={editor} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default WysiwygEditor;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { WysiwygEditor } from './WysiwygEditor';
|
|
||||||
@@ -6,7 +6,7 @@ export { Panel } from './Panel';
|
|||||||
export { TabBar } from './TabBar';
|
export { TabBar } from './TabBar';
|
||||||
export { ToastContainer, toast, showToast, type ToastType } from './Toast';
|
export { ToastContainer, toast, showToast, type ToastType } from './Toast';
|
||||||
export { ProjectSelector } from './ProjectSelector';
|
export { ProjectSelector } from './ProjectSelector';
|
||||||
export { WysiwygEditor } from './WysiwygEditor';
|
export { MilkdownEditor } from './MilkdownEditor';
|
||||||
export { Lightbox, ImageGallery, useMarkdownImages } from './Lightbox';
|
export { Lightbox, ImageGallery, useMarkdownImages } from './Lightbox';
|
||||||
export { TaskPopup } from './TaskPopup';
|
export { TaskPopup } from './TaskPopup';
|
||||||
export { ResizablePanel } from './ResizablePanel';
|
export { ResizablePanel } from './ResizablePanel';
|
||||||
|
|||||||
Reference in New Issue
Block a user