feat: 添加 Markdown 预览组件和相关样式,更新编辑器功能

This commit is contained in:
2025-12-17 14:19:54 +08:00
parent 8ca7efcf6d
commit 0d66c46156
11 changed files with 257 additions and 32 deletions

4
mod.ts
View File

@@ -1,2 +1,4 @@
import './src/index.css'
export * from './src/md-editor.ts';
export * from './src/md-editor.ts';
export * from './src/md-preview.ts';

View File

@@ -33,13 +33,17 @@
"@tiptap/starter-kit": "^3.13.0",
"@tiptap/suggestion": "^3.13.0",
"dayjs": "^1.11.19",
"github-markdown-css": "^5.8.1",
"highlight.js": "^11.11.1",
"lit-html": "^3.3.1",
"lowlight": "^3.3.0",
"marked": "^17.0.1",
"marked-highlight": "^2.2.3",
"nanoid": "^5.1.6",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"tiptap-markdown": "^0.9.0",
"turndown": "^7.2.2",
"zustand": "^5.0.9"
},
"devDependencies": {
@@ -61,7 +65,6 @@
"exports": {
".": "./dist/kv-md.js",
"./kv-md.js": "./dist/kv-md.js",
"./kv-md.css": "./dist/kv-md.css",
"./mod.ts": "./mod.ts"
"./kv-md.css": "./dist/kv-md.css"
}
}

46
pnpm-lock.yaml generated
View File

@@ -44,6 +44,9 @@ importers:
dayjs:
specifier: ^1.11.19
version: 1.11.19
github-markdown-css:
specifier: ^5.8.1
version: 5.8.1
highlight.js:
specifier: ^11.11.1
version: 11.11.1
@@ -53,6 +56,12 @@ importers:
lowlight:
specifier: ^3.3.0
version: 3.3.0
marked:
specifier: ^17.0.1
version: 17.0.1
marked-highlight:
specifier: ^2.2.3
version: 2.2.3(marked@17.0.1)
nanoid:
specifier: ^5.1.6
version: 5.1.6
@@ -65,6 +74,9 @@ importers:
tiptap-markdown:
specifier: ^0.9.0
version: 0.9.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))
turndown:
specifier: ^7.2.2
version: 7.2.2
zustand:
specifier: ^5.0.9
version: 5.0.9(@types/react@19.2.7)(react@19.2.1)
@@ -392,6 +404,9 @@ packages:
peerDependencies:
dotenv: ^17
'@mixmark-io/domino@2.2.0':
resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==}
'@noble/hashes@1.4.0':
resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==}
engines: {node: '>= 16'}
@@ -983,6 +998,10 @@ packages:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
github-markdown-css@5.8.1:
resolution: {integrity: sha512-8G+PFvqigBQSWLQjyzgpa2ThD9bo7+kDsriUIidGcRhXgmcaAWUIpCZf8DavJgc+xifjbCG+GvMyWr0XMXmc7g==}
engines: {node: '>=10'}
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
@@ -1109,6 +1128,16 @@ packages:
resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
hasBin: true
marked-highlight@2.2.3:
resolution: {integrity: sha512-FCfZRxW/msZAiasCML4isYpxyQWKEEx44vOgdn5Kloae+Qc3q4XR7WjpKKf8oMLk7JP9ZCRd2vhtclJFdwxlWQ==}
peerDependencies:
marked: '>=4 <18'
marked@17.0.1:
resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==}
engines: {node: '>= 20'}
hasBin: true
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
@@ -1317,6 +1346,9 @@ packages:
resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==}
engines: {node: '>= 6.0.0'}
turndown@7.2.2:
resolution: {integrity: sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==}
uc.micro@2.1.0:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
@@ -1649,6 +1681,8 @@ snapshots:
'@kevisual/load': 0.0.6
dotenv: 17.2.3
'@mixmark-io/domino@2.2.0': {}
'@noble/hashes@1.4.0': {}
'@peculiar/asn1-cms@2.6.0':
@@ -2240,6 +2274,8 @@ snapshots:
gensync@1.0.0-beta.2: {}
github-markdown-css@5.8.1: {}
graceful-fs@4.2.11: {}
highlight.js@11.11.1: {}
@@ -2346,6 +2382,12 @@ snapshots:
punycode.js: 2.3.1
uc.micro: 2.1.0
marked-highlight@2.2.3(marked@17.0.1):
dependencies:
marked: 17.0.1
marked@17.0.1: {}
mdurl@2.0.0: {}
mime-db@1.54.0: {}
@@ -2603,6 +2645,10 @@ snapshots:
dependencies:
tslib: 1.14.1
turndown@7.2.2:
dependencies:
'@mixmark-io/domino': 2.2.0
uc.micro@2.1.0: {}
undici-types@7.16.0: {}

View File

@@ -1,6 +1,5 @@
import { useEffect } from 'react';
import { TextEditor, TextEditorProps } from './tiptap/editor.ts';
const markdown = `
# 欢迎使用 KeVisual Markdown 编辑器!
这是一个基于 Tiptap 和 React 构建的富文本编辑器,支持 Markdown 语法。
@@ -33,20 +32,6 @@ function greet(name: string): string {
}
\`\`\`
`
const init = () => {
const editor = new TextEditor();
const editorContainer = document.getElementById('editor') as HTMLDivElement;
const opts: TextEditorProps = {
markdown,
placeholder: '输入内容...',
onUpdateHtml: (html) => {
console.log('Content changed:', html);
},
};
editor.createEditor(editorContainer, opts);
return editor;
}
export const App = () => {
// useEffect(() => {
// const editor = init();
@@ -62,9 +47,11 @@ export const App = () => {
Click me
</button>
</div>
<kv-md-editor markdown={markdown} placeholder='请输入内容...'>
</kv-md-editor>
{/* <kv-md-editor markdown={markdown} placeholder='请输入内容...'>
</kv-md-editor> */}
<kv-md-preview>
<kv-md-content>{markdown}</kv-md-content>
</kv-md-preview>
{/* <kv-template></kv-template> */}
</div>
);

View File

@@ -3,5 +3,6 @@ import './lib.ts'
import { App } from './app.tsx';
import './index.css';
import './md-editor.ts';
import './md-preview.ts';
createRoot(document.getElementById('root')!).render(<App />);

View File

@@ -17,12 +17,9 @@ class KvMdEditor extends HTMLElement {
static get observedAttributes() {
return ['markdown', 'placeholder'];
}
getId() {
return this.id;
}
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
if (oldValue === newValue) return;
switch (name) {
case 'markdown':
this._markdown = newValue || '';
@@ -78,18 +75,18 @@ class KvMdEditor extends HTMLElement {
`;
render(content, this.shadowRoot!);
let id = this.getId();
if(!id) {
let id = this.id;
if (!id) {
id = `kv-md-editor-${Math.random().toString(36).substr(2, 9)}`;
this.id = id;
}
const inlineId = `${id}-inline`;
let editor = this.shadowRoot!.querySelector(`#${inlineId}`)!;
if(!editor) {
editor = document.createElement('div');
editor.id = inlineId;
const host = document.querySelector(`#${id}`);
host!.appendChild(editor);
if (!editor) {
editor = document.createElement('div');
editor.id = inlineId;
const host = document.querySelector(`#${id}`);
host!.appendChild(editor);
}
this.editorContainer = editor as HTMLDivElement;
}

33
src/md-preview.css Normal file
View File

@@ -0,0 +1,33 @@
@import "tailwindcss";
.markdown-body {
min-width: 200px;
margin: 0 auto;
padding: 45px;
}
.markdown-body ul {
list-style-type: disc;
padding-left: 2em;
margin-top: 0.5em;
margin-bottom: 0.5em;
}
.markdown-body ul ul {
list-style-type: circle;
}
.markdown-body ul ul ul {
list-style-type: square;
}
.markdown-body li {
margin-top: 0.25em;
margin-bottom: 0.25em;
}
.markdown-body ol {
list-style-type: decimal;
padding-left: 2em;
margin-top: 0.5em;
margin-bottom: 0.5em;
}

61
src/md-preview.ts Normal file
View File

@@ -0,0 +1,61 @@
import { render } from 'lit-html';
import { html, TemplateResult } from 'lit-html';
import { md2html } from './tiptap/utils/index.ts';
import 'highlight.js/styles/github.css';
import 'github-markdown-css/github-markdown.css';
import './md-preview.css';
class KvMdPreview extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
}
getContent(): string {
const md = this.querySelector('kv-md-content')?.textContent;
if (md !== null && md !== undefined) {
return md;
}
return '';
}
getInlineDiv(opts?: { prefixId?: string }): HTMLDivElement {
const prefixId = opts?.prefixId || 'kv-md-editor';
const slotName = 'container';
let id = this.id;
if (!id) {
id = `${prefixId}-${Math.random().toString(36).substring(2, 9)}`;
this.id = id;
}
const inlineId = `${id}-inline`;
let editor = this.shadowRoot!.querySelector(`#${inlineId}`)!;
if (!editor) {
editor = document.createElement('div');
editor.id = inlineId;
editor.slot = slotName;
const host = document.querySelector(`#${id}`);
host!.appendChild(editor);
}
return editor as HTMLDivElement;
}
async asyncRender(): Promise<void> {
const mdContent = this.getContent();
const htmlContent = await md2html(mdContent);
const contentWithHtml = html`
<div class="md-preview markdown-body" .innerHTML=${htmlContent}></div>
`;
const el = this.getInlineDiv();
render(contentWithHtml, el);
}
private render(): void {
const contentWithHtml = html`
<slot name="container"></slot>
`;
render(contentWithHtml, this.shadowRoot!);
this.asyncRender();
}
}
// Define the custom element globally
customElements.define('kv-md-preview', KvMdPreview);

View File

@@ -0,0 +1,47 @@
.ProseMirror {
padding-inline: 4rem;
> * + * {
margin-top: 0.75em;
}
[data-id] {
border: 3px solid #0d0d0d;
border-radius: 0.5rem;
margin: 1rem 0;
position: relative;
margin-top: 1.5rem;
padding: 2rem 1rem 1rem;
&::before {
content: attr(data-id);
background-color: #0d0d0d;
font-size: 0.6rem;
letter-spacing: 1px;
font-weight: bold;
text-transform: uppercase;
color: #fff;
position: absolute;
top: 0;
padding: 0.25rem 0.75rem;
border-radius: 0 0 0.5rem 0.5rem;
}
}
}
.drag-handle {
align-items: center;
background: #f0f0f0;
border-radius: 0.25rem;
border: 1px solid rgba(0, 0, 0, 0.1);
cursor: grab;
display: flex;
height: 1.5rem;
justify-content: center;
width: 1.5rem;
svg {
width: 1.25rem;
height: 1.25rem;
}
}

45
src/tiptap/utils/index.ts Normal file
View File

@@ -0,0 +1,45 @@
import { Marked } from 'marked';
import TurndownService from 'turndown';
import hljs from 'highlight.js';
import { markedHighlight } from 'marked-highlight';
// import { marked } from 'marked';
const markedAndHighlight = new Marked(
markedHighlight({
emptyLangClass: 'hljs',
langPrefix: 'hljs language-',
highlight(code, lang, info) {
const language = hljs.getLanguage(lang) ? lang : 'plaintext';
return hljs.highlight(code, { language }).value;
},
}),
);
export const md2html = async (md: string) => {
const html = markedAndHighlight.parse(md);
return html;
};
export const html2md = async (html: string, opts?: TurndownService.Options) => {
const turndownService = new TurndownService({ ...opts });
// 添加代码块规则,保留语言标记
turndownService.addRule('codeBlocks', {
filter: function (node) {
return node.nodeName === 'PRE' && node.firstChild?.nodeName === 'CODE';
},
replacement: function (content, node) {
const code = node.firstChild as HTMLElement;
// 从类名中提取语言 (hljs language-xxx)
const className = code.className || '';
const language = className.match(/language-(\w+)/)?.[1] || '';
// 确保内容首尾没有多余空行
const trimmedContent = content.trim();
return `\n\n\`\`\`${language}\n${trimmedContent}\n\`\`\`\n\n`;
},
});
const md = turndownService.turndown(html);
return md;
};

3
typings.d.ts vendored
View File

@@ -9,6 +9,9 @@ declare module 'react' {
namespace JSX {
interface IntrinsicElements {
'kv-template': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
'kv-md-preview': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
'kv-md-content': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
'md': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
'kv-md-editor': DetailedHTMLProps<KvMdEditorProps, HTMLElement>;
}
}