├── .husky └── pre-commit ├── example ├── next │ ├── README.md │ ├── .eslintrc.json │ ├── public │ │ └── favicon.ico │ ├── next.config.js │ ├── src │ │ ├── pages │ │ │ ├── _app.jsx │ │ │ └── index.jsx │ │ └── styles │ │ │ ├── globals.css │ │ │ └── Home.module.css │ ├── .gitignore │ └── package.json └── web-component │ ├── tsconfig.json │ ├── src │ ├── App.tsx │ ├── MdEditorElement │ │ ├── index.css │ │ ├── index.tsx │ │ ├── MdEditorElement.tsx │ │ ├── data.md │ │ └── iconfont.css │ ├── main.tsx │ └── vite-env.d.ts │ ├── index.html │ ├── vite.config.ts │ ├── tsconfig.node.json │ ├── tsconfig.app.json │ ├── eslint.config.js │ └── package.json ├── packages ├── MdEditor │ ├── static │ │ ├── env.ts │ │ ├── index.ts │ │ └── event-name.ts │ ├── index.ts │ ├── utils │ │ ├── cache.ts │ │ ├── md-it.ts │ │ ├── event-bus.ts │ │ └── index.ts │ ├── components │ │ ├── Divider │ │ │ ├── index.tsx │ │ │ └── index.less │ │ ├── Dropdown │ │ │ └── index.less │ │ ├── Checkbox │ │ │ ├── index.less │ │ │ └── index.tsx │ │ ├── CustomScrollbar │ │ │ └── index.less │ │ ├── Icon │ │ │ ├── Github.tsx │ │ │ ├── index.tsx │ │ │ ├── Str.ts │ │ │ └── Icon.tsx │ │ └── Modal │ │ │ └── index.less │ ├── layouts │ │ ├── Content │ │ │ ├── hooks │ │ │ │ ├── type.d.ts │ │ │ │ ├── index.ts │ │ │ │ ├── useToolbarEffect.ts │ │ │ │ ├── useRemount.ts │ │ │ │ ├── useAttach.ts │ │ │ │ ├── useZoom.ts │ │ │ │ ├── useFollowCatalog.ts │ │ │ │ ├── useKatex.ts │ │ │ │ ├── useHighlight.ts │ │ │ │ ├── useTaskState.ts │ │ │ │ ├── useAutoScroll.ts │ │ │ │ ├── useCopyCode.ts │ │ │ │ └── usePasteUpload.ts │ │ │ ├── type.ts │ │ │ ├── markdownIt │ │ │ │ ├── echarts │ │ │ │ │ └── index.ts │ │ │ │ ├── mermaid │ │ │ │ │ └── index.ts │ │ │ │ ├── heading │ │ │ │ │ └── index.ts │ │ │ │ └── xss │ │ │ │ │ └── index.ts │ │ │ ├── props.ts │ │ │ ├── index.less │ │ │ ├── ContentPreview.tsx │ │ │ ├── UpdateOnDemand.tsx │ │ │ └── codemirror │ │ │ │ ├── floatingToolbar.tsx │ │ │ │ └── autocompletion.ts │ │ ├── Modals │ │ │ ├── index.tsx │ │ │ └── index.less │ │ ├── FloatingToolbar │ │ │ ├── index.less │ │ │ └── index.tsx │ │ ├── Footer │ │ │ ├── index.less │ │ │ ├── MarkdownTotal.tsx │ │ │ ├── ScrollAuto.tsx │ │ │ └── index.tsx │ │ └── Toolbar │ │ │ ├── tools │ │ │ ├── Github.tsx │ │ │ ├── Save.tsx │ │ │ ├── Sub.tsx │ │ │ ├── Sup.tsx │ │ │ ├── Bold.tsx │ │ │ ├── Code.tsx │ │ │ ├── Link.tsx │ │ │ ├── Task.tsx │ │ │ ├── Next.tsx │ │ │ ├── Revoke.tsx │ │ │ ├── Image.tsx │ │ │ ├── Quote.tsx │ │ │ ├── Italic.tsx │ │ │ ├── CodeRow.tsx │ │ │ ├── Prettier.tsx │ │ │ ├── Preview.tsx │ │ │ ├── Underline.tsx │ │ │ ├── OrderedList.tsx │ │ │ ├── StrikeThrough.tsx │ │ │ ├── UnorderedList.tsx │ │ │ ├── PreviewOnly.tsx │ │ │ ├── HtmlPreview.tsx │ │ │ ├── PageFullscreen.tsx │ │ │ ├── Fullscreen.tsx │ │ │ ├── Catalog.tsx │ │ │ ├── Table.tsx │ │ │ ├── Katex.tsx │ │ │ └── Title.tsx │ │ │ ├── index.tsx │ │ │ ├── index.less │ │ │ └── TableShape.tsx │ ├── styles │ │ ├── vars.less │ │ ├── style.less │ │ ├── preview.less │ │ └── codeMirror.less │ └── context.ts ├── config.ts ├── preview.ts ├── util.ts ├── MdCatalog │ ├── context.ts │ ├── index.less │ └── CatalogLink.tsx ├── index.ts ├── MdPreview │ └── hooks │ │ └── useExpose.ts ├── NormalFooterToolbar │ └── index.tsx ├── NormalToolbar │ └── index.tsx ├── DropdownToolbar │ └── index.tsx └── ModalToolbar │ └── index.tsx ├── dev ├── Preview │ ├── index.less │ ├── Normal │ │ └── index.tsx │ └── image │ │ └── TargetBlankExtension.js ├── env.d.ts ├── vars.less ├── SecEditor.tsx ├── Header │ ├── index.less │ └── index.tsx ├── App.tsx ├── PreviewOnly │ └── index.tsx └── style.less ├── .prettierrc ├── tsconfig.node.json ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request_cn.yaml │ ├── feature_request.yaml │ ├── bug_report_cn.yaml │ └── bug_report.yaml └── workflows │ ├── latest.yml │ ├── beta.yml │ └── close-inactive-issues.yml ├── .gitignore ├── index.html ├── tsconfig.build.json ├── tsconfig.json ├── SECURITY.md ├── LICENSE ├── scripts ├── dev.ts ├── plugins │ └── nodeService.ts ├── build.type.ts └── build.ts ├── eslint.config.mjs ├── README-CN.md ├── README.md └── package.json /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm run lint-staged -------------------------------------------------------------------------------- /example/next/README.md: -------------------------------------------------------------------------------- 1 | Example of `nextjs`. 2 | -------------------------------------------------------------------------------- /example/next/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /packages/MdEditor/static/env.ts: -------------------------------------------------------------------------------- 1 | export const isServer = typeof window === 'undefined'; 2 | -------------------------------------------------------------------------------- /example/next/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imzbf/md-editor-rt/HEAD/example/next/public/favicon.ico -------------------------------------------------------------------------------- /packages/MdEditor/index.ts: -------------------------------------------------------------------------------- 1 | import Editor from './Editor'; 2 | 3 | export default Editor; 4 | 5 | export * from './type'; 6 | -------------------------------------------------------------------------------- /example/web-component/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] 4 | } 5 | -------------------------------------------------------------------------------- /dev/Preview/index.less: -------------------------------------------------------------------------------- 1 | @import '../vars'; 2 | 3 | .project-preview { 4 | padding: 2rem; 5 | 6 | .tips-text { 7 | color: #777; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /example/web-component/src/App.tsx: -------------------------------------------------------------------------------- 1 | import './MdEditorElement'; 2 | 3 | const App = () => { 4 | return ; 5 | }; 6 | 7 | export default App; 8 | -------------------------------------------------------------------------------- /example/next/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | } 5 | 6 | module.exports = nextConfig 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "none", 4 | "semi": true, 5 | "printWidth": 90, 6 | "proseWrap": "never", 7 | "endOfLine": "auto" 8 | } 9 | -------------------------------------------------------------------------------- /packages/MdEditor/utils/cache.ts: -------------------------------------------------------------------------------- 1 | import { LRUCache } from 'lru-cache'; 2 | 3 | export const mermaidCache = new LRUCache({ 4 | max: 1000, 5 | // 缓存10分钟 6 | ttl: 600000 7 | }); 8 | -------------------------------------------------------------------------------- /packages/MdEditor/components/Divider/index.tsx: -------------------------------------------------------------------------------- 1 | import { prefix } from '~/config'; 2 | 3 | const Divider = () =>
; 4 | 5 | export default Divider; 6 | -------------------------------------------------------------------------------- /example/next/src/pages/_app.jsx: -------------------------------------------------------------------------------- 1 | import '../styles/globals.css'; 2 | import 'md-editor-rt/lib/style.css'; 3 | 4 | function MyApp({ Component, pageProps }) { 5 | return ; 6 | } 7 | 8 | export default MyApp; 9 | -------------------------------------------------------------------------------- /example/web-component/src/MdEditorElement/index.css: -------------------------------------------------------------------------------- 1 | @import 'md-editor-rt/lib/style.css'; 2 | @import 'highlight.js/styles/atom-one-dark.css'; 3 | @import 'cropperjs/dist/cropper.css'; 4 | @import 'katex/dist/katex.min.css'; 5 | @import './iconfont.css'; 6 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Content/hooks/type.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'markdown-it-image-figures'; 2 | declare module 'markdown-it-task-lists'; 3 | declare module 'markdown-it-xss'; 4 | declare module 'markdown-it-sub'; 5 | declare module 'markdown-it-sup'; 6 | -------------------------------------------------------------------------------- /packages/MdEditor/components/Divider/index.less: -------------------------------------------------------------------------------- 1 | .@{prefix}-divider { 2 | position: relative; 3 | display: inline-block; 4 | width: 1px; 5 | top: 0.1em; 6 | height: 0.9em; 7 | margin: 0 8px; 8 | background-color: var(--md-border-color); 9 | } 10 | -------------------------------------------------------------------------------- /packages/config.ts: -------------------------------------------------------------------------------- 1 | import { staticTextDefault } from '~/config'; 2 | 3 | export { prefix, config, allToolbar, allFooter, editorExtensionsAttrs } from '~/config'; 4 | 5 | export const zh_CN = staticTextDefault['zh-CN']; 6 | export const en_US = staticTextDefault['en-US']; 7 | -------------------------------------------------------------------------------- /packages/preview.ts: -------------------------------------------------------------------------------- 1 | export { default as MdPreview } from './MdPreview'; 2 | export { default as MdCatalog } from './MdCatalog'; 3 | 4 | export * from '~/layouts/Content/markdownIt/xss'; 5 | export * from './config'; 6 | export * from './util'; 7 | 8 | export * from '~/type'; 9 | -------------------------------------------------------------------------------- /example/web-component/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import App from './App.tsx'; 4 | 5 | createRoot(document.getElementById('root')!).render( 6 | 7 | 8 | 9 | ); 10 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "bundler", 6 | "allowSyntheticDefaultImports": true, 7 | "resolveJsonModule": true 8 | }, 9 | "include": ["scripts/**/*.ts", "./package.json"] 10 | } 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request_cn.yaml: -------------------------------------------------------------------------------- 1 | name: 功能建议 2 | description: 为这个项目提供一个很棒的想法 3 | body: 4 | - type: markdown 5 | attributes: 6 | value: 感谢您的参与。 7 | - type: textarea 8 | id: description 9 | attributes: 10 | label: 一个很棒的想法 11 | validations: 12 | required: true 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | *.log 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | pnpm-debug.log* 7 | 8 | node_modules 9 | *.local* 10 | 11 | # Editor directories and files 12 | .vscode/* 13 | !.vscode/extensions.json 14 | .idea 15 | .DS_Store 16 | 17 | dist 18 | 19 | lib/ 20 | 21 | example/**/yarn.lock -------------------------------------------------------------------------------- /dev/env.d.ts: -------------------------------------------------------------------------------- 1 | interface ImportMetaEnv { 2 | readonly VITE_APP_TITLE: string; 3 | // 更多环境变量... 4 | [x: string]: any; 5 | } 6 | 7 | interface ImportMeta { 8 | readonly env: ImportMetaEnv; 9 | } 10 | 11 | declare module '*.md' { 12 | const Component: ComponentOptions; 13 | export default Component; 14 | } 15 | -------------------------------------------------------------------------------- /example/web-component/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare namespace JSX { 4 | interface IntrinsicElements { 5 | 'md-editor-element': React.DetailedHTMLProps< 6 | React.HTMLAttributes, 7 | HTMLElement 8 | >; 9 | } 10 | } 11 | 12 | declare module '*.md'; 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yaml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest an idea for this project 3 | body: 4 | - type: markdown 5 | attributes: 6 | value: Thank you for your participation. 7 | - type: textarea 8 | id: description 9 | attributes: 10 | label: A great idea 11 | validations: 12 | required: true 13 | -------------------------------------------------------------------------------- /dev/Preview/Normal/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NormalToolbar } from '~~/index'; 3 | import Icon from '~~/MdEditor/components/Icon'; 4 | 5 | export default () => { 6 | return ( 7 | } 9 | onClick={console.log} 10 | key="dddd" 11 | > 12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /example/next/src/styles/globals.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | padding: 0; 4 | margin: 0; 5 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 6 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 7 | } 8 | 9 | a { 10 | color: inherit; 11 | text-decoration: none; 12 | } 13 | 14 | * { 15 | box-sizing: border-box; 16 | } 17 | -------------------------------------------------------------------------------- /example/web-component/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Web Component Demo 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/util.ts: -------------------------------------------------------------------------------- 1 | import { CDN_IDS } from './MdEditor/static'; 2 | 3 | /** 4 | * 清空组件带来的副作用,例如 5 | * 1. 使用CDN嵌入的链接,为了保证多个组件能够正常使用,组件在卸载时不会主动移除 6 | */ 7 | export const clearSideEffects = () => { 8 | Object.keys(CDN_IDS).forEach((key) => { 9 | const ele = document.getElementById(CDN_IDS[key]); 10 | 11 | if (ele) { 12 | ele.remove(); 13 | } 14 | }); 15 | }; 16 | -------------------------------------------------------------------------------- /packages/MdCatalog/context.ts: -------------------------------------------------------------------------------- 1 | import { createContext, RefObject } from 'react'; 2 | 3 | export interface CatalogContextValue { 4 | scrollElementRef?: RefObject; 5 | rootNodeRef?: RefObject; 6 | } 7 | 8 | export const CatalogContext = createContext({ 9 | scrollElementRef: undefined, 10 | rootNodeRef: undefined 11 | }); 12 | -------------------------------------------------------------------------------- /packages/MdEditor/components/Dropdown/index.less: -------------------------------------------------------------------------------- 1 | .@{prefix}-dropdown { 2 | overflow: hidden; 3 | box-sizing: border-box; 4 | position: absolute; 5 | transition: all 0.3s; 6 | opacity: 1; 7 | z-index: 20000; 8 | background-color: var(--md-bk-color); 9 | 10 | &-hidden { 11 | opacity: 0; 12 | visibility: hidden; 13 | } 14 | 15 | &-overlay { 16 | margin-top: 6px; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /example/web-component/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | import markdown from '@vavt/vite-plugin-import-markdown'; 4 | import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'; 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | plugins: [ 9 | react(), 10 | markdown(), 11 | cssInjectedByJsPlugin({ 12 | styleId: 'custom_id' 13 | }) 14 | ] 15 | }); 16 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Modals/index.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | import ClipModal from './Clip'; 3 | 4 | interface ModalsProps { 5 | clipVisible: boolean; 6 | onCancel: () => void; 7 | onOk: (data?: any) => void; 8 | } 9 | 10 | const Modals = (props: ModalsProps) => { 11 | return ( 12 | 13 | ); 14 | }; 15 | 16 | // 链接弹窗\图片弹窗\帮助弹窗 17 | export default memo(Modals); 18 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | develop 7 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /packages/MdEditor/components/Checkbox/index.less: -------------------------------------------------------------------------------- 1 | .@{prefix}-checkbox { 2 | cursor: pointer; 3 | width: 12px; 4 | height: 12px; 5 | border: 1px solid var(--md-border-color); 6 | background-color: var(--md-bk-color-outstand); 7 | border-radius: 2px; 8 | line-height: 1; 9 | text-align: center; 10 | 11 | &::after { 12 | content: ''; 13 | 14 | font-weight: bold; 15 | } 16 | 17 | &-checked { 18 | &::after { 19 | content: '✓'; 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /example/next/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | -------------------------------------------------------------------------------- /example/next/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "md-editor-rt": "latest", 13 | "next": "12.1.6", 14 | "react": "18.1.0", 15 | "react-dom": "18.1.0" 16 | }, 17 | "devDependencies": { 18 | "eslint": "8.16.0", 19 | "eslint-config-next": "12.1.6" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Content/hooks/index.ts: -------------------------------------------------------------------------------- 1 | import useAutoScroll from './useAutoScroll'; 2 | import useCodeMirror from './useCodeMirror'; 3 | import useCopyCode from './useCopyCode'; 4 | import useMarkdownIt from './useMarkdownIt'; 5 | import useResize from './useResize'; 6 | import useZoom from './useZoom'; 7 | export * from './useTaskState'; 8 | export * from './useRemount'; 9 | export * from './useFollowCatalog'; 10 | 11 | export { useAutoScroll, useCodeMirror, useMarkdownIt, useZoom, useCopyCode, useResize }; 12 | -------------------------------------------------------------------------------- /packages/index.ts: -------------------------------------------------------------------------------- 1 | export { default as MdEditor } from './MdEditor'; 2 | 3 | export { default as NormalToolbar } from './NormalToolbar'; 4 | export { default as DropdownToolbar } from './DropdownToolbar'; 5 | export { default as ModalToolbar } from './ModalToolbar'; 6 | export { default as MdModal } from './MdEditor/components/Modal'; 7 | export { default as StrIcon } from './MdEditor/components/Icon/Str'; 8 | export { default as NormalFooterToolbar } from './NormalFooterToolbar'; 9 | 10 | export * from './preview'; 11 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Content/type.ts: -------------------------------------------------------------------------------- 1 | import { EditorView } from '@codemirror/view'; 2 | import { FocusOption } from '~/type'; 3 | 4 | export interface ContentExposeParam { 5 | /** 6 | * 手动聚焦 7 | * 8 | * @param options 聚焦时光标的位置,不提供默认上次失焦时的位置 9 | */ 10 | focus(options?: FocusOption): void; 11 | /** 12 | * 获取当前选中的文本 13 | */ 14 | getSelectedText(): string | undefined; 15 | /** 16 | * 重置已经存在的历史记录 17 | */ 18 | resetHistory(): void; 19 | getEditorView(): EditorView | undefined; 20 | } 21 | -------------------------------------------------------------------------------- /packages/MdEditor/utils/md-it.ts: -------------------------------------------------------------------------------- 1 | import { Token } from 'markdown-it'; 2 | 3 | export const mergeAttrs = (token: Token, addAttrs: [string, string][]) => { 4 | const tmpAttrs = token.attrs ? token.attrs.slice() : []; 5 | 6 | addAttrs.forEach((addAttr) => { 7 | const i = token.attrIndex(addAttr[0]); 8 | if (i < 0) { 9 | tmpAttrs.push(addAttr); 10 | } else { 11 | tmpAttrs[i] = tmpAttrs[i].slice() as [string, string]; 12 | tmpAttrs[i][1] += ` ${addAttr[1]}`; 13 | } 14 | }); 15 | 16 | return tmpAttrs; 17 | }; 18 | -------------------------------------------------------------------------------- /dev/vars.less: -------------------------------------------------------------------------------- 1 | @color: #222; 2 | @colorDark: #999; 3 | 4 | @colorReverse: #eee; 5 | @colorDarkReverse: #222; 6 | 7 | @borderColor: #e6e6e6; 8 | @borderColorDark: #2d2d2d; 9 | 10 | @borderColorReverse: #bebebe; 11 | @borderColorDarkReverse: #e6e6e6; 12 | 13 | @bkColor: #fff; 14 | @bkColorDark: #000; 15 | 16 | @codeBkColor: #282c34; 17 | @codeBkColorDark: #1a1a1a; 18 | 19 | @hover: rgb(230, 230, 230); 20 | @hoverdark: rgb(70, 70, 70); 21 | 22 | // 在默认背景下凸出背景 23 | @bbkColor: #ececec; 24 | @bbkColorDark: #111; 25 | 26 | // 模仿antd的主色调 27 | @primaryColor: #73d13d; 28 | -------------------------------------------------------------------------------- /packages/MdEditor/static/index.ts: -------------------------------------------------------------------------------- 1 | import { prefix } from '../config'; 2 | 3 | export const CDN_IDS: Record = { 4 | hljs: `${prefix}-hljs`, 5 | hlcss: `${prefix}-hlCss`, 6 | prettier: `${prefix}-prettier`, 7 | prettierMD: `${prefix}-prettierMD`, 8 | cropperjs: `${prefix}-cropper`, 9 | croppercss: `${prefix}-cropperCss`, 10 | screenfull: `${prefix}-screenfull`, 11 | mermaidM: `${prefix}-mermaid-m`, 12 | mermaid: `${prefix}-mermaid`, 13 | katexjs: `${prefix}-katex`, 14 | katexcss: `${prefix}-katexCss`, 15 | echarts: `${prefix}-echarts` 16 | }; 17 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/FloatingToolbar/index.less: -------------------------------------------------------------------------------- 1 | .@{prefix}-floating-toolbar { 2 | padding: 4px; 3 | display: flex; 4 | align-items: center; 5 | } 6 | 7 | .@{prefix}-floating-toolbar-container { 8 | opacity: 0; 9 | transition: opacity 120ms ease-out; 10 | transition-delay: 20ms; 11 | will-change: opacity; 12 | 13 | &[data-state='visible'] { 14 | opacity: 1; 15 | } 16 | 17 | .cm-tooltip-arrow { 18 | transition: opacity 120ms ease-out; 19 | opacity: 0; 20 | } 21 | 22 | &[data-state='visible'] .cm-tooltip-arrow { 23 | opacity: 1; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /example/web-component/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "lib": ["ES2023"], 5 | "module": "ESNext", 6 | "skipLibCheck": true, 7 | 8 | /* Bundler mode */ 9 | "moduleResolution": "bundler", 10 | "allowImportingTsExtensions": true, 11 | "isolatedModules": true, 12 | "moduleDetection": "force", 13 | "noEmit": true, 14 | 15 | /* Linting */ 16 | "strict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noFallthroughCasesInSwitch": true 20 | }, 21 | "include": ["vite.config.ts"] 22 | } 23 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Footer/index.less: -------------------------------------------------------------------------------- 1 | .@{prefix} { 2 | &-footer { 3 | height: 24px; 4 | flex-shrink: 0; 5 | font-size: 12px; 6 | color: var(--md-color); 7 | border-top: 1px solid var(--md-border-color); 8 | display: flex; 9 | justify-content: space-between; 10 | 11 | &-item { 12 | display: inline-flex; 13 | align-items: center; 14 | height: 100%; 15 | padding: 0 10px; 16 | } 17 | 18 | &-item + &-item { 19 | padding-left: 0; 20 | } 21 | 22 | &-label { 23 | padding-right: 5px; 24 | line-height: 1; 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/MdPreview/hooks/useExpose.ts: -------------------------------------------------------------------------------- 1 | import { ForwardedRef, useImperativeHandle } from 'react'; 2 | import { RERENDER } from '~/static/event-name'; 3 | import { ExposePreviewParam, MdPreviewStaticProps } from '~/type'; 4 | import eventBus from '~/utils/event-bus'; 5 | 6 | export const useExpose = (props: MdPreviewStaticProps, ref: ForwardedRef) => { 7 | const { editorId } = props; 8 | 9 | useImperativeHandle(ref, () => { 10 | const exposeParam: ExposePreviewParam = { 11 | rerender() { 12 | eventBus.emit(editorId, RERENDER); 13 | } 14 | }; 15 | 16 | return exposeParam; 17 | }, [editorId]); 18 | }; 19 | -------------------------------------------------------------------------------- /example/web-component/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"] 24 | } 25 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Content/hooks/useToolbarEffect.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | import { ToolbarNames } from '~/type'; 3 | 4 | const arraysEqual = (prev: ToolbarNames[], curr: ToolbarNames[]) => { 5 | if (prev === curr) return true; 6 | if (prev.length !== curr.length) return false; 7 | for (let i = 0; i < prev.length; i++) { 8 | if (prev[i] !== curr[i]) return false; 9 | } 10 | return true; 11 | }; 12 | 13 | export const useToolbarEffect = (effect: React.EffectCallback, deps: ToolbarNames[]) => { 14 | const prev = useRef([]); 15 | 16 | if (!prev.current || !arraysEqual(prev.current, deps)) { 17 | prev.current = deps; 18 | effect(); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /packages/MdEditor/components/CustomScrollbar/index.less: -------------------------------------------------------------------------------- 1 | @scrollbar-width: 6px; 2 | 3 | .@{prefix}-custom-scrollbar { 4 | position: relative; 5 | overflow: hidden; 6 | height: 100%; 7 | 8 | &__track { 9 | position: absolute; 10 | top: 0; 11 | right: 0; 12 | width: @scrollbar-width; 13 | height: 100%; 14 | background: var(--md-scrollbar-bg-color); 15 | } 16 | 17 | &__thumb { 18 | position: absolute; 19 | width: @scrollbar-width; 20 | background: var(--md-scrollbar-thumb-color); 21 | border-radius: 4px; 22 | cursor: pointer; 23 | transition: background 0.2s; 24 | 25 | &:hover { 26 | background: var(--md-scrollbar-thumb-hover-color); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Content/hooks/useRemount.ts: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect } from 'react'; 2 | import { EditorContext } from '~/context'; 3 | import { ContentPreviewProps } from '../props'; 4 | 5 | /** 6 | * @description 不考虑 onRemount 的变化,默认开发者已经维护好该方法 7 | */ 8 | export const useRemount = (props: ContentPreviewProps, html: string, key: string) => { 9 | const { onRemount } = props; 10 | const { setting } = useContext(EditorContext); 11 | 12 | useEffect(() => { 13 | onRemount?.(); 14 | }, [html, key, onRemount]); 15 | 16 | useEffect(() => { 17 | if (setting.preview || setting.htmlPreview) { 18 | onRemount?.(); 19 | } 20 | }, [setting.preview, setting.htmlPreview, onRemount]); 21 | }; 22 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Footer/MarkdownTotal.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useMemo } from 'react'; 2 | import { prefix } from '~/config'; 3 | import { EditorContext } from '~/context'; 4 | 5 | const MarkdownTotal = ({ modelValue }: { modelValue: string }) => { 6 | const { usedLanguageText } = useContext(EditorContext); 7 | 8 | return useMemo(() => { 9 | return ( 10 |
11 | 14 | {modelValue.length || 0} 15 |
16 | ); 17 | }, [usedLanguageText, modelValue]); 18 | }; 19 | 20 | export default MarkdownTotal; 21 | -------------------------------------------------------------------------------- /dev/Preview/image/TargetBlankExtension.js: -------------------------------------------------------------------------------- 1 | const TargetBlankExtension = (md) => { 2 | const defaultRender = 3 | md.renderer.rules.link_open || 4 | function (tokens, idx, options, env, self) { 5 | return self.renderToken(tokens, idx, options); 6 | }; 7 | 8 | md.renderer.rules.link_open = function (tokens, idx, options, env, self) { 9 | const aIndex = tokens[idx].attrIndex('target'); 10 | 11 | if (aIndex < 0) { 12 | tokens[idx].attrPush(['target', '_blank']); 13 | } else { 14 | tokens[idx].attrs[aIndex][1] = '_blank'; 15 | } 16 | 17 | // pass token to default renderer. 18 | return defaultRender(tokens, idx, options, env, self); 19 | }; 20 | }; 21 | 22 | export default TargetBlankExtension; 23 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Content/hooks/useAttach.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useContext } from 'react'; 2 | import { EditorContext } from '~/context'; 3 | import { TEXTAREA_FOCUS } from '~/static/event-name'; 4 | import { FocusOption } from '~/type'; 5 | import eventBus from '~/utils/event-bus'; 6 | import CodeMirrorUt from '../codemirror'; 7 | /** 8 | * 一些附带的设置 9 | * 10 | * @deprecated 暂时没啥用 11 | */ 12 | const useAttach = (codeMirrorUt: RefObject) => { 13 | const { editorId } = useContext(EditorContext); 14 | 15 | eventBus.on(editorId, { 16 | name: TEXTAREA_FOCUS, 17 | callback(options: FocusOption) { 18 | codeMirrorUt.current?.focus(options); 19 | } 20 | }); 21 | }; 22 | 23 | export default useAttach; 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report_cn.yaml: -------------------------------------------------------------------------------- 1 | name: 创建一个Bug 2 | description: 描述一下这个Bug 3 | body: 4 | - type: markdown 5 | attributes: 6 | value: 感谢您的参与。如果您正在寻求帮助,请从[此处](https://github.com/imzbf/md-editor-rt/discussions)创建讨论。 7 | - type: textarea 8 | id: description 9 | attributes: 10 | label: 描述这个Bug 11 | validations: 12 | required: true 13 | - type: input 14 | id: version 15 | attributes: 16 | label: 版本号 17 | description: 描述您的开发环境,例如编辑器、`nodejs`、浏览器的版本等。 18 | validations: 19 | required: true 20 | - type: input 21 | id: reproduction 22 | attributes: 23 | label: 问题重现链接 24 | description: 如果您提供在线代码环境或代码存储库或代码的`zip`文件,它可以帮助我快速发现问题。 25 | validations: 26 | required: false 27 | -------------------------------------------------------------------------------- /packages/NormalFooterToolbar/index.tsx: -------------------------------------------------------------------------------- 1 | import { memo, MouseEvent, ReactNode } from 'react'; 2 | import { prefix } from '~/config'; 3 | import { classnames } from '~/utils'; 4 | 5 | export interface NormalFooterToolbarProps { 6 | children: ReactNode; 7 | onClick?: (e: MouseEvent) => void; 8 | disabled?: boolean; 9 | } 10 | 11 | const NormalFooterToolbar = (props: NormalFooterToolbarProps) => { 12 | return ( 13 |
{ 19 | if (props.disabled) return; 20 | props.onClick?.(e); 21 | }} 22 | > 23 | {props.children} 24 |
25 | ); 26 | }; 27 | 28 | export default memo(NormalFooterToolbar); 29 | -------------------------------------------------------------------------------- /packages/MdEditor/components/Icon/Github.tsx: -------------------------------------------------------------------------------- 1 | export interface GithubProps { 2 | className: string; 3 | } 4 | 5 | const Github = (props: GithubProps) => ( 6 | 16 | 17 | 18 | 19 | ); 20 | export default Github; 21 | -------------------------------------------------------------------------------- /packages/MdEditor/components/Checkbox/index.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { prefix } from '~/config'; 3 | import { classnames } from '~/utils'; 4 | 5 | interface CheckBoxProps { 6 | checked: boolean; 7 | onChange: (checked: boolean) => void; 8 | disabled?: boolean; 9 | } 10 | 11 | const Checkbox = (props: CheckBoxProps) => { 12 | const handleClick = useCallback(() => { 13 | if (!props.disabled) { 14 | props.onChange(!props.checked); 15 | } 16 | }, [props]); 17 | 18 | return ( 19 |
27 | ); 28 | }; 29 | 30 | export default Checkbox; 31 | -------------------------------------------------------------------------------- /example/web-component/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js'; 2 | import globals from 'globals'; 3 | import reactHooks from 'eslint-plugin-react-hooks'; 4 | import reactRefresh from 'eslint-plugin-react-refresh'; 5 | import tseslint from 'typescript-eslint'; 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }] 23 | } 24 | } 25 | ); 26 | -------------------------------------------------------------------------------- /.github/workflows/latest.yml: -------------------------------------------------------------------------------- 1 | name: Npm Latest Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | publish: 10 | name: Build And Publish 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Get Node 14 | uses: actions/setup-node@v4 15 | with: 16 | node-version: 20 17 | registry-url: https://registry.npmjs.org/ 18 | 19 | - name: Checkout Code 20 | uses: actions/checkout@v4 21 | 22 | - name: Install 23 | uses: borales/actions-yarn@v5 24 | with: 25 | cmd: install 26 | 27 | - name: Build 28 | uses: borales/actions-yarn@v5 29 | with: 30 | cmd: build 31 | 32 | - name: Publish 33 | run: npm publish --access public 34 | env: 35 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 36 | -------------------------------------------------------------------------------- /example/web-component/src/MdEditorElement/index.tsx: -------------------------------------------------------------------------------- 1 | import r2wc from '@r2wc/react-to-web-component'; 2 | import MdEditorElement from './MdEditorElement'; 3 | 4 | const convertReact2WebComponent = ( 5 | Component: Parameters[0], 6 | options?: Parameters[1] 7 | ) => { 8 | const WebComponent = r2wc(Component, options); 9 | 10 | class WebComponentWithStyle extends WebComponent { 11 | constructor() { 12 | super(); 13 | 14 | const styleTag = document.getElementById('custom_id'); 15 | if (styleTag) { 16 | this.shadowRoot?.appendChild(styleTag.cloneNode(true)); 17 | } 18 | } 19 | } 20 | 21 | return WebComponentWithStyle; 22 | }; 23 | 24 | customElements.define( 25 | 'md-editor-element', 26 | convertReact2WebComponent(MdEditorElement, { 27 | shadow: 'open' 28 | }) 29 | ); 30 | -------------------------------------------------------------------------------- /packages/MdEditor/components/Icon/index.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { EditorContext } from '~/context'; 3 | import Icon, { IconName } from './Icon'; 4 | 5 | const IconIns = (props: { name: IconName }) => { 6 | const { customIcon } = useContext(EditorContext); 7 | 8 | const item = customIcon[props.name]; 9 | 10 | // 自定义的图标总是对象结构,唯一的copy图标只会通过Str判断内容 11 | if (typeof item === 'object') { 12 | const CusIcon = item.component; 13 | 14 | // 无论是class组件还是函数组件,都是function类型 15 | return typeof CusIcon === 'function' ? ( 16 | 17 | ) : ( 18 | 23 | ); 24 | } 25 | 26 | return ; 27 | }; 28 | 29 | export default IconIns; 30 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/FloatingToolbar/index.tsx: -------------------------------------------------------------------------------- 1 | import { prefix } from '~/config'; 2 | import { EditorContext } from '~/context'; 3 | import { useFloatingToolbarValue } from '~/layouts/Content/codemirror/floatingToolbar'; 4 | import { useBarRender } from '~/layouts/Toolbar/hooks'; 5 | import { ToolbarNames } from '~/type'; 6 | 7 | const FloatingToolbar = () => { 8 | const contextValue = useFloatingToolbarValue(); 9 | const { barRender } = useBarRender(); 10 | 11 | return ( 12 | 13 |
14 | {contextValue.floatingToolbars.map((barItem: ToolbarNames, idx) => { 15 | return barRender(barItem, `floating-${idx}`); 16 | })} 17 |
18 |
19 | ); 20 | }; 21 | 22 | export default FloatingToolbar; 23 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "allowJs": false, 6 | "allowSyntheticDefaultImports": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "module": "ESNext", 9 | "moduleResolution": "Node", 10 | "strict": true, 11 | "jsx": "preserve", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "esModuleInterop": true, 15 | "lib": ["ESNext", "DOM"], 16 | "skipLibCheck": true, 17 | "declaration": true, 18 | "emitDeclarationOnly": true, 19 | "declarationDir": "./lib/types", 20 | "outDir": "./lib", 21 | "baseUrl": ".", 22 | "paths": { 23 | "~~/*": ["./packages/*"], 24 | "~/*": ["./packages/MdEditor/*"] 25 | } 26 | }, 27 | "include": ["packages/**/*.ts", "packages/**/*.tsx"] 28 | } 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "allowJs": false, 6 | "allowSyntheticDefaultImports": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "module": "ESNext", 9 | "moduleResolution": "Node", 10 | "strict": true, 11 | "jsx": "preserve", // react-jsx会提示不需要引用React,不支持16的版本 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "esModuleInterop": true, 15 | "lib": ["ESNext", "DOM"], 16 | "skipLibCheck": true, 17 | "noEmit": true, 18 | "baseUrl": ".", 19 | "paths": { 20 | "@/*": ["dev/*"], 21 | "~~/*": ["packages/*"], 22 | "~/*": ["packages/MdEditor/*"] 23 | } 24 | }, 25 | "include": ["dev/**/*.ts", "dev/**/*.tsx", "packages/**/*.ts", "packages/**/*.tsx"], 26 | "references": [{ "path": "./tsconfig.node.json" }] 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/beta.yml: -------------------------------------------------------------------------------- 1 | name: Npm Beta Publish 2 | 3 | on: 4 | push: 5 | branches: [beta] 6 | pull_request: 7 | branches: [beta] 8 | 9 | jobs: 10 | publish: 11 | name: Build And Publish 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Get Node 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: 20 18 | registry-url: https://registry.npmjs.org/ 19 | 20 | - name: Checkout Code 21 | uses: actions/checkout@v4 22 | 23 | - name: Install 24 | uses: borales/actions-yarn@v5 25 | with: 26 | cmd: install 27 | 28 | - name: Build 29 | uses: borales/actions-yarn@v5 30 | with: 31 | cmd: build 32 | 33 | - name: Publish 34 | run: npm publish --access public --tag beta 35 | env: 36 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 37 | -------------------------------------------------------------------------------- /.github/workflows/close-inactive-issues.yml: -------------------------------------------------------------------------------- 1 | name: Close Inactive Issues 2 | on: 3 | schedule: 4 | - cron: '0 6 * * *' # 每天早上6点运行,github的cron表达式规则和其他的不太一致 5 | 6 | jobs: 7 | close-issues: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | issues: write 11 | pull-requests: write 12 | steps: 13 | - uses: actions/stale@v5 14 | with: 15 | days-before-issue-stale: 14 # 14天无操作标记不活跃 16 | days-before-issue-close: 14 # 再过14天不活跃关闭 17 | stale-issue-label: 'stale' 18 | stale-issue-message: 'This issue is stale because it has been open for 7 days with no activity.' 19 | close-issue-message: 'This issue was closed because it has been inactive for 7 days since being marked as stale.' 20 | days-before-pr-stale: -1 21 | days-before-pr-close: -1 22 | repo-token: ${{ secrets.WORKFLOW_TOKEN }} # 勾选workflow列 23 | -------------------------------------------------------------------------------- /packages/MdEditor/styles/vars.less: -------------------------------------------------------------------------------- 1 | // 统一的前缀 2 | @prefix: ~'md-editor'; 3 | 4 | .css-vars(@isDark) { 5 | --md-color: if(@isDark, #999, #3f4a54); 6 | --md-hover-color: if(@isDark, #bbb, #000); 7 | --md-bk-color: if(@isDark, #000, #fff); 8 | --md-bk-color-outstand: if(@isDark, #333, #f2f2f2); 9 | --md-bk-hover-color: if(@isDark, #1b1a1a, #f5f7fa); 10 | --md-border-color: if(@isDark, #2d2d2d, #e6e6e6); 11 | --md-border-hover-color: if(@isDark, #636262, #b9b9b9); 12 | --md-border-active-color: if(@isDark, #777, #999); 13 | --md-modal-mask: #00000073; 14 | --md-modal-shadow: if(@isDark, 0px 6px 24px 2px #00000066, 0px 6px 24px 2px #00000019); 15 | --md-scrollbar-bg-color: if(@isDark, #0f0f0f, #e2e2e2); 16 | --md-scrollbar-thumb-color: if(@isDark, #2d2d2d, #0000004d); 17 | --md-scrollbar-thumb-hover-color: if(@isDark, #3a3a3a, #00000059); 18 | --md-scrollbar-thumb-active-color: if(@isDark, #3a3a3a, #00000061); 19 | } 20 | -------------------------------------------------------------------------------- /packages/MdEditor/styles/style.less: -------------------------------------------------------------------------------- 1 | @import '~/styles/vars.less'; 2 | 3 | @import '~/components/Checkbox/index.less'; 4 | @import '~/components/Divider/index.less'; 5 | @import '~/components/Dropdown/index.less'; 6 | @import '~/components/Modal/index.less'; 7 | @import '~/components/CustomScrollbar/index.less'; 8 | 9 | @import '~/layouts/Content/index.less'; 10 | @import '~/layouts/Footer/index.less'; 11 | @import '~/layouts/Modals/index.less'; 12 | @import '~/layouts/Toolbar/index.less'; 13 | @import '~/layouts/FloatingToolbar/index.less'; 14 | 15 | @import '~/styles/codeMirror.less'; 16 | 17 | @import '~/styles/preview.less'; 18 | 19 | .@{prefix}-fullscreen { 20 | position: fixed !important; 21 | top: 0; 22 | right: 0; 23 | bottom: 0; 24 | left: 0; 25 | width: auto !important; 26 | height: auto !important; 27 | z-index: 10000; 28 | } 29 | 30 | .@{prefix}-disabled { 31 | cursor: not-allowed !important; 32 | opacity: 0.6; 33 | } 34 | -------------------------------------------------------------------------------- /packages/NormalToolbar/index.tsx: -------------------------------------------------------------------------------- 1 | import { memo, MouseEvent, ReactNode, useMemo } from 'react'; 2 | import { prefix } from '~/config'; 3 | 4 | export interface NormalToolbarProps { 5 | title?: string; 6 | children?: ReactNode; 7 | /** 8 | * @deprecated 使用children代替 9 | */ 10 | trigger?: ReactNode; 11 | onClick: (e: MouseEvent) => void; 12 | disabled?: boolean; 13 | } 14 | 15 | const NormalToolbar = (props: NormalToolbarProps) => { 16 | const className = useMemo(() => { 17 | return `${prefix}-toolbar-item${props.disabled ? ' ' + prefix + '-disabled' : ''}`; 18 | }, [props.disabled]); 19 | 20 | return ( 21 | 32 | ); 33 | }; 34 | 35 | export default memo(NormalToolbar); 36 | -------------------------------------------------------------------------------- /dev/SecEditor.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { MdEditor, StrIcon } from '~~/index'; 3 | import data from './data.md'; 4 | 5 | export default () => { 6 | const [text, setText] = useState(data); 7 | const [visible, setVisible] = useState(false); 8 | const changeVisible = () => { 9 | setVisible((prev) => { 10 | return !prev; 11 | }); 12 | }; 13 | 14 | return ( 15 |
16 |
17 | 18 |
19 | 20 | {visible && ( 21 | ', 27 | }} 28 | // onDrop={(e) => { 29 | // console.log('ee', e); 30 | // }} 31 | /> 32 | )} 33 |
34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | I provide security updates only for actively supported versions. 6 | Please upgrade to a supported version to ensure you receive the latest patches. 7 | 8 | | Version | Supported | 9 | | ------- | --------- | 10 | | 6.x | ✅ | 11 | | <6.x | ❌ | 12 | 13 | --- 14 | 15 | ## Reporting a Vulnerability 16 | 17 | If you discover a security vulnerability, **please do not report it publicly**. 18 | Instead, use the following secure channel: 19 | 20 | - Email: [zbfcqtl@gmail.com](mailto:zbfcqtl@gmail.com) 21 | - GitHub Security Advisories: [Report via GitHub](https://github.com/imzbf/md-editor-rt/security/advisories/new) 22 | 23 | ### What to Expect 24 | 25 | - I will acknowledge your report as soon. 26 | - Once fixed, I will release a patched version. 27 | - If I determine the issue is not a security vulnerability, I will explain the reasoning. 28 | 29 | Thank you for helping keep this project secure! 30 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Toolbar/tools/Github.tsx: -------------------------------------------------------------------------------- 1 | import { linkTo } from '@vavt/util'; 2 | import { memo, useContext } from 'react'; 3 | import Icon from '~/components/Icon'; 4 | import { prefix } from '~/config'; 5 | import { EditorContext } from '~/context'; 6 | import { classnames } from '~/utils'; 7 | 8 | const ToolbarGithub = () => { 9 | const { usedLanguageText: ult, showToolbarName, disabled } = useContext(EditorContext); 10 | 11 | return ( 12 | 26 | ); 27 | }; 28 | 29 | export default memo(ToolbarGithub); 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yaml: -------------------------------------------------------------------------------- 1 | name: Create An Issue 2 | description: Describe a problem 3 | body: 4 | - type: markdown 5 | attributes: 6 | value: Thank you for your participation. If you are looking for support, participate in the discussion from [here](https://github.com/imzbf/md-editor-rt/discussions). 7 | - type: textarea 8 | id: description 9 | attributes: 10 | label: Describe the issue 11 | validations: 12 | required: true 13 | - type: input 14 | id: version 15 | attributes: 16 | label: Procedure version 17 | description: Describe your development environment, such as version of editor or `nodejs` or browser, etc 18 | validations: 19 | required: true 20 | - type: input 21 | id: reproduction 22 | attributes: 23 | label: Reproduction link 24 | description: It can help me find problems quickly. If you provide an online code environment or a code repository or a `zip` file for code. 25 | validations: 26 | required: false 27 | -------------------------------------------------------------------------------- /example/next/src/styles/Home.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | width: 1170px; 3 | margin: 0 auto; 4 | padding: 0 2rem; 5 | } 6 | 7 | .main { 8 | min-height: 100vh; 9 | padding: 4rem 0; 10 | flex: 1; 11 | display: flex; 12 | flex-direction: column; 13 | justify-content: center; 14 | align-items: center; 15 | } 16 | 17 | .title a { 18 | color: #0070f3; 19 | text-decoration: none; 20 | } 21 | 22 | .title a:hover, 23 | .title a:focus, 24 | .title a:active { 25 | text-decoration: underline; 26 | } 27 | 28 | .title { 29 | margin: 0; 30 | line-height: 1.15; 31 | font-size: 4rem; 32 | } 33 | 34 | .title, 35 | .description { 36 | text-align: center; 37 | } 38 | 39 | .description { 40 | margin: 4rem 0; 41 | line-height: 1.5; 42 | font-size: 1.5rem; 43 | } 44 | 45 | .code { 46 | background: #fafafa; 47 | border-radius: 5px; 48 | padding: 0.75rem; 49 | font-size: 1.1rem; 50 | font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, 51 | Bitstream Vera Sans Mono, Courier New, monospace; 52 | } 53 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Content/hooks/useZoom.ts: -------------------------------------------------------------------------------- 1 | import mediumZoom from 'medium-zoom'; 2 | import { useContext, useEffect } from 'react'; 3 | import { EditorContext } from '~/context'; 4 | 5 | import { ContentPreviewProps } from '../props'; 6 | 7 | /** 8 | * 放大图片 9 | * 10 | * @param props 基础属性 11 | * @param html 编译后的html 12 | */ 13 | const useZoom = (props: ContentPreviewProps, html: string) => { 14 | const { editorId, setting } = useContext(EditorContext); 15 | 16 | useEffect(() => { 17 | if (props.noImgZoomIn) { 18 | return; 19 | } 20 | 21 | const zoomHander = () => { 22 | const imgs = document.querySelectorAll( 23 | `#${editorId}-preview img:not(.not-zoom):not(.medium-zoom-image)` 24 | ); 25 | 26 | const zoom = mediumZoom(imgs, { 27 | background: '#00000073' 28 | }); 29 | 30 | return () => { 31 | zoom.detach(); 32 | }; 33 | }; 34 | 35 | return zoomHander(); 36 | }, [editorId, html, props.noImgZoomIn, setting]); 37 | }; 38 | 39 | export default useZoom; 40 | -------------------------------------------------------------------------------- /packages/MdEditor/context.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | import { staticTextDefault } from './config'; 3 | import { ContextType } from './type'; 4 | 5 | export const defaultContextValue: ContextType = { 6 | editorId: '', 7 | tabWidth: 2, 8 | theme: 'light', 9 | language: 'zh-CN', 10 | highlight: { 11 | css: '', 12 | js: '' 13 | }, 14 | showCodeRowNumber: false, 15 | usedLanguageText: staticTextDefault['zh-CN'], 16 | previewTheme: 'default', 17 | customIcon: {}, 18 | rootRef: null, 19 | disabled: undefined, 20 | showToolbarName: false, 21 | setting: { 22 | preview: false, 23 | htmlPreview: false, 24 | previewOnly: false, 25 | pageFullscreen: false, 26 | fullscreen: false 27 | }, 28 | updateSetting: () => {}, 29 | tableShape: [6, 4], 30 | catalogVisible: false, 31 | noUploadImg: false, 32 | noPrettier: false, 33 | codeTheme: 'default', 34 | defToolbars: [], 35 | floatingToolbars: [] 36 | }; 37 | 38 | export const EditorContext = createContext(defaultContextValue); 39 | -------------------------------------------------------------------------------- /example/next/src/pages/index.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { MdEditor } from 'md-editor-rt'; 3 | import Head from 'next/head'; 4 | import styles from '../styles/Home.module.css'; 5 | 6 | export default function Home() { 7 | const [text, setText] = useState('# md-editor-v3'); 8 | 9 | return ( 10 |
11 | 12 | Create Next App 13 | 14 | 15 | 16 | 17 |
18 |

19 | Welcome to Next.js! 20 |

21 | 22 |

23 | Get started by editing pages/index.js 24 |

25 | {/* in nuxt, editor-id must be set. */} 26 | 27 |
28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 imzbf 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Toolbar/tools/Save.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useContext } from 'react'; 2 | import Icon from '~/components/Icon'; 3 | import { prefix } from '~/config'; 4 | import { EditorContext } from '~/context'; 5 | import { ON_SAVE } from '~/static/event-name'; 6 | import { classnames } from '~/utils'; 7 | import bus from '~/utils/event-bus'; 8 | 9 | const ToolbarSave = () => { 10 | const { 11 | editorId, 12 | usedLanguageText: ult, 13 | showToolbarName, 14 | disabled 15 | } = useContext(EditorContext); 16 | 17 | return ( 18 | 32 | ); 33 | }; 34 | 35 | export default memo(ToolbarSave); 36 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Toolbar/tools/Sub.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useContext } from 'react'; 2 | import Icon from '~/components/Icon'; 3 | import { prefix } from '~/config'; 4 | import { EditorContext } from '~/context'; 5 | import { REPLACE } from '~/static/event-name'; 6 | import { classnames } from '~/utils'; 7 | import bus from '~/utils/event-bus'; 8 | 9 | const ToolbarSub = () => { 10 | const { 11 | editorId, 12 | usedLanguageText: ult, 13 | showToolbarName, 14 | disabled 15 | } = useContext(EditorContext); 16 | 17 | return ( 18 | 32 | ); 33 | }; 34 | 35 | export default memo(ToolbarSub); 36 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Toolbar/tools/Sup.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useContext } from 'react'; 2 | import Icon from '~/components/Icon'; 3 | import { prefix } from '~/config'; 4 | import { EditorContext } from '~/context'; 5 | import { REPLACE } from '~/static/event-name'; 6 | import { classnames } from '~/utils'; 7 | import bus from '~/utils/event-bus'; 8 | 9 | const ToolbarSup = () => { 10 | const { 11 | editorId, 12 | usedLanguageText: ult, 13 | showToolbarName, 14 | disabled 15 | } = useContext(EditorContext); 16 | 17 | return ( 18 | 32 | ); 33 | }; 34 | 35 | export default memo(ToolbarSup); 36 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Toolbar/tools/Bold.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useContext } from 'react'; 2 | import Icon from '~/components/Icon'; 3 | import { prefix } from '~/config'; 4 | import { EditorContext } from '~/context'; 5 | import { REPLACE } from '~/static/event-name'; 6 | import { classnames } from '~/utils'; 7 | import bus from '~/utils/event-bus'; 8 | 9 | const ToolbarBold = () => { 10 | const { 11 | editorId, 12 | usedLanguageText: ult, 13 | showToolbarName, 14 | disabled 15 | } = useContext(EditorContext); 16 | 17 | return ( 18 | 32 | ); 33 | }; 34 | 35 | export default memo(ToolbarBold); 36 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Toolbar/tools/Code.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useContext } from 'react'; 2 | import Icon from '~/components/Icon'; 3 | import { prefix } from '~/config'; 4 | import { EditorContext } from '~/context'; 5 | import { REPLACE } from '~/static/event-name'; 6 | import { classnames } from '~/utils'; 7 | import bus from '~/utils/event-bus'; 8 | 9 | const ToolbarCode = () => { 10 | const { 11 | editorId, 12 | usedLanguageText: ult, 13 | showToolbarName, 14 | disabled 15 | } = useContext(EditorContext); 16 | 17 | return ( 18 | 32 | ); 33 | }; 34 | 35 | export default memo(ToolbarCode); 36 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Toolbar/tools/Link.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useContext } from 'react'; 2 | import Icon from '~/components/Icon'; 3 | import { prefix } from '~/config'; 4 | import { EditorContext } from '~/context'; 5 | import { REPLACE } from '~/static/event-name'; 6 | import { classnames } from '~/utils'; 7 | import bus from '~/utils/event-bus'; 8 | 9 | const ToolbarLink = () => { 10 | const { 11 | editorId, 12 | usedLanguageText: ult, 13 | showToolbarName, 14 | disabled 15 | } = useContext(EditorContext); 16 | 17 | return ( 18 | 32 | ); 33 | }; 34 | 35 | export default memo(ToolbarLink); 36 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Toolbar/tools/Task.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useContext } from 'react'; 2 | import Icon from '~/components/Icon'; 3 | import { prefix } from '~/config'; 4 | import { EditorContext } from '~/context'; 5 | import { REPLACE } from '~/static/event-name'; 6 | import { classnames } from '~/utils'; 7 | import bus from '~/utils/event-bus'; 8 | 9 | const ToolbarTask = () => { 10 | const { 11 | editorId, 12 | usedLanguageText: ult, 13 | showToolbarName, 14 | disabled 15 | } = useContext(EditorContext); 16 | 17 | return ( 18 | 32 | ); 33 | }; 34 | 35 | export default memo(ToolbarTask); 36 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Toolbar/tools/Next.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useContext } from 'react'; 2 | import Icon from '~/components/Icon'; 3 | import { prefix } from '~/config'; 4 | import { EditorContext } from '~/context'; 5 | import { CTRL_SHIFT_Z } from '~/static/event-name'; 6 | import { classnames } from '~/utils'; 7 | import bus from '~/utils/event-bus'; 8 | 9 | const ToolbarNext = () => { 10 | const { 11 | editorId, 12 | usedLanguageText: ult, 13 | showToolbarName, 14 | disabled 15 | } = useContext(EditorContext); 16 | 17 | return ( 18 | 32 | ); 33 | }; 34 | 35 | export default memo(ToolbarNext); 36 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Toolbar/tools/Revoke.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useContext } from 'react'; 2 | import Icon from '~/components/Icon'; 3 | import { prefix } from '~/config'; 4 | import { EditorContext } from '~/context'; 5 | import { CTRL_Z } from '~/static/event-name'; 6 | import { classnames } from '~/utils'; 7 | import bus from '~/utils/event-bus'; 8 | 9 | const ToolbarRevoke = () => { 10 | const { 11 | editorId, 12 | usedLanguageText: ult, 13 | showToolbarName, 14 | disabled 15 | } = useContext(EditorContext); 16 | 17 | return ( 18 | 32 | ); 33 | }; 34 | 35 | export default memo(ToolbarRevoke); 36 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Toolbar/tools/Image.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useContext } from 'react'; 2 | import Icon from '~/components/Icon'; 3 | import { prefix } from '~/config'; 4 | import { EditorContext } from '~/context'; 5 | import { REPLACE } from '~/static/event-name'; 6 | import { classnames } from '~/utils'; 7 | import bus from '~/utils/event-bus'; 8 | 9 | const ToolbarImage = () => { 10 | const { 11 | editorId, 12 | usedLanguageText: ult, 13 | showToolbarName, 14 | disabled 15 | } = useContext(EditorContext); 16 | 17 | return ( 18 | 32 | ); 33 | }; 34 | 35 | export default memo(ToolbarImage); 36 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Toolbar/tools/Quote.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useContext } from 'react'; 2 | import Icon from '~/components/Icon'; 3 | import { prefix } from '~/config'; 4 | import { EditorContext } from '~/context'; 5 | import { REPLACE } from '~/static/event-name'; 6 | import { classnames } from '~/utils'; 7 | import bus from '~/utils/event-bus'; 8 | 9 | const ToolbarQuote = () => { 10 | const { 11 | editorId, 12 | usedLanguageText: ult, 13 | showToolbarName, 14 | disabled 15 | } = useContext(EditorContext); 16 | 17 | return ( 18 | 32 | ); 33 | }; 34 | 35 | export default memo(ToolbarQuote); 36 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Toolbar/tools/Italic.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useContext } from 'react'; 2 | import Icon from '~/components/Icon'; 3 | import { prefix } from '~/config'; 4 | import { EditorContext } from '~/context'; 5 | import { REPLACE } from '~/static/event-name'; 6 | import { classnames } from '~/utils'; 7 | import bus from '~/utils/event-bus'; 8 | 9 | const ToolbarItalic = () => { 10 | const { 11 | editorId, 12 | usedLanguageText: ult, 13 | showToolbarName, 14 | disabled 15 | } = useContext(EditorContext); 16 | 17 | return ( 18 | 32 | ); 33 | }; 34 | 35 | export default memo(ToolbarItalic); 36 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Toolbar/tools/CodeRow.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useContext } from 'react'; 2 | import Icon from '~/components/Icon'; 3 | import { prefix } from '~/config'; 4 | import { EditorContext } from '~/context'; 5 | import { REPLACE } from '~/static/event-name'; 6 | import { classnames } from '~/utils'; 7 | import bus from '~/utils/event-bus'; 8 | 9 | const ToolbarCodeRow = () => { 10 | const { 11 | editorId, 12 | usedLanguageText: ult, 13 | showToolbarName, 14 | disabled 15 | } = useContext(EditorContext); 16 | 17 | return ( 18 | 32 | ); 33 | }; 34 | 35 | export default memo(ToolbarCodeRow); 36 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Toolbar/tools/Prettier.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useContext } from 'react'; 2 | import Icon from '~/components/Icon'; 3 | import { prefix } from '~/config'; 4 | import { EditorContext } from '~/context'; 5 | import { REPLACE } from '~/static/event-name'; 6 | import { classnames } from '~/utils'; 7 | import bus from '~/utils/event-bus'; 8 | 9 | const ToolbarPrettier = () => { 10 | const { 11 | editorId, 12 | usedLanguageText: ult, 13 | showToolbarName, 14 | disabled 15 | } = useContext(EditorContext); 16 | 17 | return ( 18 | 32 | ); 33 | }; 34 | 35 | export default memo(ToolbarPrettier); 36 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Toolbar/tools/Preview.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useContext } from 'react'; 2 | import Icon from '~/components/Icon'; 3 | import { prefix } from '~/config'; 4 | import { EditorContext } from '~/context'; 5 | import { classnames } from '~/utils'; 6 | 7 | const ToolbarPreview = () => { 8 | const { 9 | usedLanguageText: ult, 10 | showToolbarName, 11 | disabled, 12 | setting, 13 | updateSetting 14 | } = useContext(EditorContext); 15 | 16 | return ( 17 | 35 | ); 36 | }; 37 | 38 | export default memo(ToolbarPreview); 39 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Toolbar/tools/Underline.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useContext } from 'react'; 2 | import Icon from '~/components/Icon'; 3 | import { prefix } from '~/config'; 4 | import { EditorContext } from '~/context'; 5 | import { REPLACE } from '~/static/event-name'; 6 | import { classnames } from '~/utils'; 7 | import bus from '~/utils/event-bus'; 8 | 9 | const ToolbarUnderline = () => { 10 | const { 11 | editorId, 12 | usedLanguageText: ult, 13 | showToolbarName, 14 | disabled 15 | } = useContext(EditorContext); 16 | 17 | return ( 18 | 32 | ); 33 | }; 34 | 35 | export default memo(ToolbarUnderline); 36 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Footer/ScrollAuto.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useContext } from 'react'; 2 | import Checkbox from '~/components/Checkbox'; 3 | import { prefix } from '~/config'; 4 | import { EditorContext } from '~/context'; 5 | import { classnames } from '~/utils'; 6 | 7 | interface ScrollAutoProps { 8 | scrollAuto: boolean; 9 | onScrollAutoChange: (v: boolean) => void; 10 | } 11 | 12 | const ScrollAuto = (props: ScrollAutoProps) => { 13 | const { usedLanguageText, disabled } = useContext(EditorContext); 14 | 15 | return ( 16 |
19 | 28 | 33 |
34 | ); 35 | }; 36 | 37 | export default memo(ScrollAuto); 38 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Toolbar/tools/OrderedList.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useContext } from 'react'; 2 | import Icon from '~/components/Icon'; 3 | import { prefix } from '~/config'; 4 | import { EditorContext } from '~/context'; 5 | import { REPLACE } from '~/static/event-name'; 6 | import { classnames } from '~/utils'; 7 | import bus from '~/utils/event-bus'; 8 | 9 | const ToolbarOrderedList = () => { 10 | const { 11 | editorId, 12 | usedLanguageText: ult, 13 | showToolbarName, 14 | disabled 15 | } = useContext(EditorContext); 16 | 17 | return ( 18 | 34 | ); 35 | }; 36 | 37 | export default memo(ToolbarOrderedList); 38 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Toolbar/tools/StrikeThrough.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useContext } from 'react'; 2 | import Icon from '~/components/Icon'; 3 | import { prefix } from '~/config'; 4 | import { EditorContext } from '~/context'; 5 | import { REPLACE } from '~/static/event-name'; 6 | import { classnames } from '~/utils'; 7 | import bus from '~/utils/event-bus'; 8 | 9 | const ToolbarStrikeThrough = () => { 10 | const { 11 | editorId, 12 | usedLanguageText: ult, 13 | showToolbarName, 14 | disabled 15 | } = useContext(EditorContext); 16 | 17 | return ( 18 | 34 | ); 35 | }; 36 | 37 | export default memo(ToolbarStrikeThrough); 38 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Toolbar/tools/UnorderedList.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useContext } from 'react'; 2 | import Icon from '~/components/Icon'; 3 | import { prefix } from '~/config'; 4 | import { EditorContext } from '~/context'; 5 | import { REPLACE } from '~/static/event-name'; 6 | import { classnames } from '~/utils'; 7 | import bus from '~/utils/event-bus'; 8 | 9 | const ToolbarUnorderedList = () => { 10 | const { 11 | editorId, 12 | usedLanguageText: ult, 13 | showToolbarName, 14 | disabled 15 | } = useContext(EditorContext); 16 | 17 | return ( 18 | 34 | ); 35 | }; 36 | 37 | export default memo(ToolbarUnorderedList); 38 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Toolbar/tools/PreviewOnly.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useContext } from 'react'; 2 | import Icon from '~/components/Icon'; 3 | import { prefix } from '~/config'; 4 | import { EditorContext } from '~/context'; 5 | import { classnames } from '~/utils'; 6 | 7 | const ToolbarPreviewOnly = () => { 8 | const { 9 | usedLanguageText: ult, 10 | showToolbarName, 11 | disabled, 12 | setting, 13 | updateSetting 14 | } = useContext(EditorContext); 15 | 16 | return ( 17 | 37 | ); 38 | }; 39 | 40 | export default memo(ToolbarPreviewOnly); 41 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Toolbar/tools/HtmlPreview.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useContext } from 'react'; 2 | import Icon from '~/components/Icon'; 3 | import { prefix } from '~/config'; 4 | import { EditorContext } from '~/context'; 5 | import { classnames } from '~/utils'; 6 | 7 | const ToolbarHtmlPreview = () => { 8 | const { 9 | usedLanguageText: ult, 10 | setting, 11 | updateSetting, 12 | showToolbarName, 13 | disabled 14 | } = useContext(EditorContext); 15 | 16 | return ( 17 | 38 | ); 39 | }; 40 | 41 | export default memo(ToolbarHtmlPreview); 42 | -------------------------------------------------------------------------------- /example/web-component/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web-component", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite build --watch & vite preview", 8 | "build": "tsc -b && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "cropperjs": "^1.6.2", 13 | "highlight.js": "^11.10.0", 14 | "katex": "^0.16.22", 15 | "md-editor-rt": "latest", 16 | "mermaid": "^11.9.0", 17 | "prettier": "^3.3.3", 18 | "react": "^18.3.1", 19 | "react-dom": "^18.3.1", 20 | "screenfull": "^6.0.2" 21 | }, 22 | "devDependencies": { 23 | "@eslint/js": "^9.9.0", 24 | "@r2wc/react-to-web-component": "^2.0.3", 25 | "@types/react": "^18.3.3", 26 | "@types/react-dom": "^18.3.0", 27 | "@vavt/vite-plugin-import-markdown": "^1.0.1", 28 | "@vitejs/plugin-react": "^4.3.1", 29 | "eslint": "^9.9.0", 30 | "eslint-plugin-react-hooks": "^5.1.0-rc.0", 31 | "eslint-plugin-react-refresh": "^0.4.9", 32 | "globals": "^15.9.0", 33 | "typescript": "^5.5.3", 34 | "typescript-eslint": "^8.0.1", 35 | "vite": "^5.4.1", 36 | "vite-plugin-css-injected-by-js": "^3.5.1" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /scripts/dev.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { fileURLToPath } from 'url'; 3 | import markdown from '@vavt/vite-plugin-import-markdown'; 4 | import react from '@vitejs/plugin-react'; 5 | import { createServer } from 'vite'; 6 | 7 | import nodeService from './plugins/nodeService'; 8 | 9 | const __dirname = fileURLToPath(new URL('..', import.meta.url)); 10 | const resolvePath = (p: string) => path.resolve(__dirname, p); 11 | 12 | void (async () => { 13 | const server = await createServer({ 14 | base: '/', 15 | publicDir: resolvePath('dev/public'), 16 | server: { 17 | port: 6101, 18 | host: '0.0.0.0' 19 | }, 20 | resolve: { 21 | alias: { 22 | '@': resolvePath('dev'), 23 | '~~': resolvePath('packages'), 24 | '~': resolvePath('packages/MdEditor') 25 | } 26 | }, 27 | plugins: [react(), nodeService(), markdown()], 28 | css: { 29 | modules: { 30 | localsConvention: 'camelCase' // 默认只支持驼峰,修改为同事支持横线和驼峰 31 | }, 32 | preprocessorOptions: { 33 | less: { 34 | javascriptEnabled: true 35 | } 36 | } 37 | } 38 | }); 39 | 40 | await server.listen(); 41 | 42 | server.printUrls(); 43 | })(); 44 | -------------------------------------------------------------------------------- /example/web-component/src/MdEditorElement/MdEditorElement.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { MdEditor, config } from 'md-editor-rt'; 3 | import screenfull from 'screenfull'; 4 | import katex from 'katex'; 5 | import Cropper from 'cropperjs'; 6 | import mermaid from 'mermaid'; 7 | import highlight from 'highlight.js'; 8 | 9 | // >=3.0 10 | import * as prettier from 'prettier'; 11 | import parserMarkdown from 'prettier/plugins/markdown'; 12 | 13 | import './index.css'; 14 | 15 | import md from './data.md'; 16 | 17 | config({ 18 | iconfontType: 'class', 19 | editorExtensions: { 20 | prettier: { 21 | prettierInstance: prettier, 22 | parserMarkdownInstance: parserMarkdown 23 | }, 24 | highlight: { 25 | instance: highlight 26 | }, 27 | screenfull: { 28 | instance: screenfull 29 | }, 30 | katex: { 31 | instance: katex 32 | }, 33 | cropper: { 34 | instance: Cropper 35 | }, 36 | mermaid: { 37 | instance: mermaid 38 | } 39 | } 40 | }); 41 | 42 | const MdEditorElement = () => { 43 | const [text, setText] = useState(md); 44 | return ; 45 | }; 46 | 47 | export default MdEditorElement; 48 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Toolbar/tools/PageFullscreen.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useContext } from 'react'; 2 | import Icon from '~/components/Icon'; 3 | import { prefix } from '~/config'; 4 | import { EditorContext } from '~/context'; 5 | import { classnames } from '~/utils'; 6 | 7 | const ToolbarPageFullscreen = () => { 8 | const { 9 | setting, 10 | usedLanguageText: ult, 11 | updateSetting, 12 | showToolbarName, 13 | disabled 14 | } = useContext(EditorContext); 15 | 16 | return ( 17 | 37 | ); 38 | }; 39 | 40 | export default memo(ToolbarPageFullscreen); 41 | -------------------------------------------------------------------------------- /packages/MdCatalog/index.less: -------------------------------------------------------------------------------- 1 | .@{prefix}-catalog { 2 | &-indicator { 3 | height: 18px; 4 | width: 4px; 5 | background-color: #73d13d; 6 | position: absolute; 7 | border-radius: 4px; 8 | transition: top 0.3s; 9 | } 10 | 11 | & > &-link { 12 | padding: 5px 8px; 13 | } 14 | 15 | &-link { 16 | padding: 5px 0 5px 1em; 17 | display: flex; 18 | flex-direction: column; 19 | 20 | span { 21 | display: inline-block; 22 | width: 100%; 23 | position: relative; 24 | overflow: hidden; 25 | color: var(--md-color); 26 | white-space: nowrap; 27 | text-overflow: ellipsis; 28 | transition: color 0.3s; 29 | cursor: pointer; 30 | line-height: 18px; 31 | 32 | &:hover { 33 | color: #73d13d; 34 | } 35 | } 36 | 37 | .@{prefix}-catalog-wrapper > & { 38 | padding-top: 5px; 39 | padding-bottom: 5px; 40 | 41 | &:first-of-type { 42 | padding-top: 10px; 43 | } 44 | 45 | &:last-of-type { 46 | padding-bottom: 0; 47 | } 48 | } 49 | } 50 | 51 | &-active { 52 | & > span { 53 | color: #73d13d; 54 | } 55 | } 56 | } 57 | 58 | .@{prefix}-catalog-dark { 59 | .css-vars(true); 60 | } 61 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Toolbar/tools/Fullscreen.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useContext } from 'react'; 2 | import Icon from '~/components/Icon'; 3 | import { prefix } from '~/config'; 4 | import { EditorContext } from '~/context'; 5 | import { classnames } from '~/utils'; 6 | import { useSreenfull } from '../hooks'; 7 | 8 | const ToolbarFullscreen = () => { 9 | const { 10 | setting, 11 | usedLanguageText: ult, 12 | showToolbarName, 13 | disabled 14 | } = useContext(EditorContext); 15 | 16 | // 全屏功能 17 | const { fullscreenHandler } = useSreenfull(); 18 | 19 | return ( 20 | 39 | ); 40 | }; 41 | 42 | export default memo(ToolbarFullscreen); 43 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Toolbar/tools/Catalog.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useContext } from 'react'; 2 | import Icon from '~/components/Icon'; 3 | import { prefix } from '~/config'; 4 | import { EditorContext } from '~/context'; 5 | import { CHANGE_CATALOG_VISIBLE } from '~/static/event-name'; 6 | import { classnames } from '~/utils'; 7 | import bus from '~/utils/event-bus'; 8 | 9 | const ToolbarCatalog = () => { 10 | const { 11 | editorId, 12 | usedLanguageText: ult, 13 | showToolbarName, 14 | disabled, 15 | catalogVisible 16 | } = useContext(EditorContext); 17 | 18 | return ( 19 | 39 | ); 40 | }; 41 | 42 | export default memo(ToolbarCatalog); 43 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Content/markdownIt/echarts/index.ts: -------------------------------------------------------------------------------- 1 | import markdownit from 'markdown-it'; 2 | import { RefObject } from 'react'; 3 | import { prefix } from '~/config'; 4 | import { Themes } from '~/type'; 5 | 6 | const EchartsPlugin = (md: markdownit, options: { themeRef: RefObject }) => { 7 | const temp = md.renderer.rules.fence!.bind(md.renderer.rules); 8 | md.renderer.rules.fence = (tokens, idx, ops, env, slf) => { 9 | const token = tokens[idx]; 10 | const code = token.content.trim(); 11 | if (token.info === 'echarts') { 12 | token.attrSet('class', `${prefix}-echarts`); 13 | token.attrSet('data-echarts-theme', options.themeRef.current); 14 | 15 | if (token.map && token.level === 0) { 16 | const closeLine = token.map[1] - 1; 17 | const closeLineText = env.srcLines[closeLine]?.trim(); 18 | const isClosingFence = !!closeLineText?.startsWith('```'); 19 | 20 | token.attrSet('data-closed', `${isClosingFence}`); 21 | token.attrSet('data-line', String(token.map[0])); 22 | } 23 | 24 | return `
${md.utils.escapeHtml(code)}
`; 25 | } 26 | 27 | return temp(tokens, idx, ops, env, slf); 28 | }; 29 | }; 30 | 31 | export default EchartsPlugin; 32 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Content/hooks/useFollowCatalog.ts: -------------------------------------------------------------------------------- 1 | import { createSmoothScroll } from '@vavt/util'; 2 | import { useCallback, useContext, useRef } from 'react'; 3 | import { EditorContext } from '~/context'; 4 | import { prefix } from '~~/config'; 5 | 6 | const smoothScroll = createSmoothScroll(); 7 | 8 | export const useFollowCatalog = () => { 9 | const { editorId } = useContext(EditorContext); 10 | 11 | const activeSync = useRef(true); 12 | 13 | const onCatalogActive = useCallback( 14 | (_toc: unknown, ele: HTMLDivElement) => { 15 | const scroller = document.querySelector( 16 | `#${editorId} .${prefix}-catalog-editor` 17 | ); 18 | 19 | if (!ele || !activeSync.current || !scroller) { 20 | return; 21 | } 22 | 23 | const dis = ele.offsetTop - scroller.scrollTop; 24 | if (dis > 100) { 25 | smoothScroll(scroller, ele.offsetTop - 100); 26 | } else if (dis < 100) { 27 | smoothScroll(scroller, ele.offsetTop - 100); 28 | } 29 | }, 30 | [editorId] 31 | ); 32 | 33 | const onMouseEnter = useCallback(() => (activeSync.current = false), []); 34 | 35 | const onMouseLeave = useCallback(() => (activeSync.current = true), []); 36 | 37 | return { 38 | onCatalogActive, 39 | onMouseEnter, 40 | onMouseLeave 41 | }; 42 | }; 43 | -------------------------------------------------------------------------------- /packages/DropdownToolbar/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, memo, useContext, useMemo } from 'react'; 2 | import Dropdown from '~/components/Dropdown'; 3 | import { prefix } from '~/config'; 4 | import { EditorContext } from '~/context'; 5 | 6 | export interface DropdownToolbarProps { 7 | title?: string; 8 | visible: boolean; 9 | /** 10 | * @deprecated 使用children代替 11 | */ 12 | trigger?: ReactNode; 13 | onChange: (visible: boolean) => void; 14 | overlay: ReactNode; 15 | children?: ReactNode; 16 | disabled?: boolean; 17 | } 18 | 19 | const DropdownToolbar = (props: DropdownToolbarProps) => { 20 | const { editorId } = useContext(EditorContext); 21 | const className = useMemo(() => { 22 | return `${prefix}-toolbar-item${props.disabled ? ' ' + prefix + '-disabled' : ''}`; 23 | }, [props.disabled]); 24 | 25 | return ( 26 | 33 | 41 | 42 | ); 43 | }; 44 | 45 | export default memo(DropdownToolbar); 46 | -------------------------------------------------------------------------------- /scripts/plugins/nodeService.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { fileURLToPath } from 'url'; 4 | import multiparty from 'multiparty'; 5 | import { Plugin, ViteDevServer } from 'vite'; 6 | 7 | const __dirname = fileURLToPath(new URL('..', import.meta.url)); 8 | const LOCAL_IMG_PATH = path.resolve(__dirname, '../dev/public/temp.local'); 9 | 10 | export default (): Plugin => { 11 | return { 12 | name: 'node-service', 13 | configureServer: (server: ViteDevServer) => { 14 | server.middlewares.use((req, res, next) => { 15 | if (/^\/api\/img\/upload$/.test(req.url)) { 16 | if (!fs.existsSync(LOCAL_IMG_PATH)) { 17 | fs.mkdirSync(LOCAL_IMG_PATH, { 18 | recursive: true 19 | }); 20 | } 21 | 22 | const form = new multiparty.Form({ 23 | uploadDir: LOCAL_IMG_PATH 24 | }); 25 | 26 | form.parse(req, (err, fields, files) => { 27 | const filename = files.file[0].path 28 | .replace(/\\/g, '/') 29 | .split('md-editor-rt/dev/public')[1]; 30 | 31 | res.end( 32 | JSON.stringify({ 33 | code: 0, 34 | url: filename 35 | }) 36 | ); 37 | }); 38 | } else { 39 | next(); 40 | } 41 | }); 42 | } 43 | }; 44 | }; 45 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Content/hooks/useKatex.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | import { globalConfig } from '~/config'; 3 | import { appendHandler } from '~/utils/dom'; 4 | import { CDN_IDS } from '~~/MdEditor/static'; 5 | 6 | import { ContentPreviewProps } from '../props'; 7 | 8 | /** 9 | * 注册katex扩展到marked 10 | * 11 | * @param props 内容组件props 12 | * @param marked - 13 | */ 14 | const useKatex = (props: ContentPreviewProps) => { 15 | // katex是否加载完成 16 | const katexRef = useRef(globalConfig.editorExtensions.katex!.instance); 17 | const [katexInited, setKatexInited] = useState(!!katexRef.current); 18 | 19 | useEffect(() => { 20 | if (props.noKatex || katexRef.current) { 21 | return; 22 | } 23 | // 标签引入katex 24 | 25 | // 获取相应的扩展配置链接 26 | const { editorExtensions, editorExtensionsAttrs } = globalConfig; 27 | 28 | appendHandler( 29 | 'script', 30 | { 31 | ...editorExtensionsAttrs.katex?.js, 32 | src: editorExtensions.katex!.js, 33 | id: CDN_IDS.katexjs, 34 | onload() { 35 | katexRef.current = window.katex; 36 | setKatexInited(true); 37 | } 38 | }, 39 | 'katex' 40 | ); 41 | appendHandler('link', { 42 | ...editorExtensionsAttrs.katex?.css, 43 | rel: 'stylesheet', 44 | href: editorExtensions.katex!.css, 45 | id: CDN_IDS.katexcss 46 | }); 47 | }, [props.noKatex]); 48 | 49 | return { katexRef, katexInited }; 50 | }; 51 | 52 | export default useKatex; 53 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Content/markdownIt/mermaid/index.ts: -------------------------------------------------------------------------------- 1 | import markdownit from 'markdown-it'; 2 | import { RefObject } from 'react'; 3 | import { prefix } from '~/config'; 4 | import { Themes } from '~/type'; 5 | import { mermaidCache } from '~/utils/cache'; 6 | 7 | const MermaidPlugin = (md: markdownit, options: { themeRef: RefObject }) => { 8 | const temp = md.renderer.rules.fence!.bind(md.renderer.rules); 9 | md.renderer.rules.fence = (tokens, idx, ops, env, slf) => { 10 | const token = tokens[idx]; 11 | const code = token.content.trim(); 12 | if (token.info === 'mermaid') { 13 | token.attrSet('class', `${prefix}-mermaid`); 14 | token.attrSet('data-mermaid-theme', options.themeRef.current); 15 | 16 | if (token.map && token.level === 0) { 17 | const closeLine = token.map[1] - 1; 18 | const closeLineText = env.srcLines[closeLine]?.trim(); 19 | const isClosingFence = !!closeLineText?.startsWith('```'); 20 | 21 | token.attrSet('data-closed', `${isClosingFence}`); 22 | token.attrSet('data-line', String(token.map[0])); 23 | } 24 | 25 | const mermaidHtml = mermaidCache.get(code) as string; 26 | 27 | if (mermaidHtml) { 28 | token.attrSet('data-processed', ''); 29 | token.attrSet('data-content', code); 30 | return `

${mermaidHtml}

`; 31 | } 32 | 33 | return `
${md.utils.escapeHtml(code)}
`; 34 | } 35 | 36 | return temp(tokens, idx, ops, env, slf); 37 | }; 38 | }; 39 | 40 | export default MermaidPlugin; 41 | -------------------------------------------------------------------------------- /packages/MdEditor/static/event-name.ts: -------------------------------------------------------------------------------- 1 | export const ON_SAVE = 'onSave'; 2 | 3 | // 切换目录展示 4 | export const CHANGE_CATALOG_VISIBLE = 'changeCatalogVisible'; 5 | 6 | // 切换屏幕全屏 7 | export const CHANGE_FULL_SCREEN = 'changeFullscreen'; 8 | 9 | // 切换页面全屏 10 | export const PAGE_FULL_SCREEN_CHANGED = 'pageFullscreenChanged'; 11 | 12 | // 屏幕全屏状态变化 13 | export const FULL_SCREEN_CHANGED = 'fullscreenChanged'; 14 | 15 | // 预览状态变化 16 | export const PREVIEW_CHANGED = 'previewChanged'; 17 | 18 | export const PREVIEW_ONLY_CHANGED = 'previewOnlyChanged'; 19 | 20 | // html代码变化 21 | export const HTML_PREVIEW_CHANGED = 'htmlPreviewChanged'; 22 | 23 | // 目录状态变化 24 | export const CATALOG_VISIBLE_CHANGED = 'catalogVisibleChanged'; 25 | 26 | // 输入框获取焦点 27 | export const TEXTAREA_FOCUS = 'textareaFocus'; 28 | 29 | // 构建完成监听事件 30 | export const BUILD_FINISHED = 'buildFinished'; 31 | 32 | // 错误捕获 33 | export const ERROR_CATCHER = 'errorCatcher'; 34 | 35 | // 替换文本 36 | export const REPLACE = 'replace'; 37 | 38 | // 上传图片 39 | export const UPLOAD_IMAGE = 'uploadImage'; 40 | 41 | // 撤回 42 | export const CTRL_Z = 'ctrlZ'; 43 | 44 | // 前进 45 | export const CTRL_SHIFT_Z = 'ctrlShiftZ'; 46 | 47 | // 目录变化 48 | export const CATALOG_CHANGED = 'catalogChanged'; 49 | 50 | // 主动推送目录 51 | export const PUSH_CATALOG = 'pushCatalog'; 52 | 53 | export const RERENDER = 'rerender'; 54 | 55 | // 监听dom原始事件 56 | export const EVENT_LISTENER = 'eventListener'; 57 | 58 | // 任务状态变化 59 | export const TASK_STATE_CHANGED = 'taskStateChanged'; 60 | 61 | // 获取编辑器view 62 | export const SEND_EDITOR_VIEW = 'sendEditorView'; 63 | export const GET_EDITOR_VIEW = 'getEditorView'; 64 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Content/markdownIt/heading/index.ts: -------------------------------------------------------------------------------- 1 | import markdownit from 'markdown-it'; 2 | import { RefObject } from 'react'; 3 | import { HeadList, MdHeadingId } from '~/type'; 4 | 5 | export interface HeadingPluginOps extends markdownit.Options { 6 | mdHeadingId: MdHeadingId; 7 | headsRef: RefObject; 8 | } 9 | 10 | const HeadingPlugin = (md: markdownit, options: HeadingPluginOps) => { 11 | md.renderer.rules.heading_open = (tokens, idx) => { 12 | const token = tokens[idx]; 13 | 14 | const text = 15 | tokens[idx + 1].children?.reduce((p, c) => { 16 | return ( 17 | p + 18 | (['text', 'code_inline', 'math_inline'].includes(c.type) ? c.content || '' : '') 19 | ); 20 | }, '') || ''; 21 | 22 | const level = token.markup.length as 1 | 2 | 3 | 4 | 5 | 6; 23 | 24 | options.headsRef.current.push({ 25 | text, 26 | level, 27 | line: token.map![0], 28 | currentToken: token, 29 | nextToken: tokens[idx + 1] 30 | }); 31 | 32 | if (token.map && token.level === 0) { 33 | token.attrSet( 34 | 'id', 35 | options.mdHeadingId({ 36 | text, 37 | level, 38 | index: options.headsRef.current.length, 39 | currentToken: token, 40 | nextToken: tokens[idx + 1] 41 | }) 42 | ); 43 | } 44 | 45 | return md.renderer.renderToken(tokens, idx, options); 46 | }; 47 | 48 | md.renderer.rules.heading_close = (tokens, idx, opts, _env, self) => { 49 | return self.renderToken(tokens, idx, opts); 50 | }; 51 | }; 52 | 53 | export default HeadingPlugin; 54 | -------------------------------------------------------------------------------- /packages/MdEditor/utils/event-bus.ts: -------------------------------------------------------------------------------- 1 | export interface BusEvent { 2 | name: string; 3 | callback: (p?: any, p2?: any) => any; 4 | } 5 | 6 | class Bus { 7 | // 事件池 8 | pools: { [race: string]: { [eventName: string]: Array<(p?: any) => any> } } = {}; 9 | 10 | // 移除事件监听 11 | remove(race: string, name: string, func: (...p: any) => any) { 12 | const targetRace = this.pools[race]; 13 | const events = targetRace && this.pools[race][name]; 14 | 15 | if (events) { 16 | this.pools[race][name] = events.filter((item) => item !== func); 17 | } 18 | } 19 | 20 | // 清空全部事件,由于单一实例,多次注册会被共享内容 21 | clear(race: string) { 22 | this.pools[race] = {}; 23 | } 24 | 25 | // 注册事件监听 26 | on(race: string, event: BusEvent) { 27 | if (!this.pools[race]) { 28 | this.pools[race] = {}; 29 | } 30 | 31 | if (!this.pools[race][event.name]) { 32 | this.pools[race][event.name] = []; 33 | } 34 | 35 | this.pools[race][event.name].push(event.callback); 36 | 37 | return this.pools[race][event.name].includes(event.callback); 38 | } 39 | 40 | // 触发事件 41 | emit(race: string, name: string, ...params: unknown[]) { 42 | // 存在由于部分组件展示没有监听事件,却触发了事件的情况。现在视为正常情况! 43 | if (!this.pools[race]) { 44 | this.pools[race] = {}; 45 | } 46 | 47 | const targetRace = this.pools[race]; 48 | const events = targetRace[name]; 49 | 50 | if (events) { 51 | events.forEach((item) => { 52 | try { 53 | item(...params); 54 | } catch (error) { 55 | console.error(`${name} monitor event exception!`, error); 56 | } 57 | }); 58 | } 59 | } 60 | } 61 | 62 | export default new Bus(); 63 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Content/hooks/useHighlight.ts: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useRef, useState } from 'react'; 2 | import { globalConfig } from '~/config'; 3 | import { EditorContext } from '~/context'; 4 | import { CDN_IDS } from '~/static'; 5 | import { appendHandler, updateHandler } from '~/utils/dom'; 6 | import { ContentPreviewProps } from '../props'; 7 | 8 | /** 9 | * 注册代码高亮扩展到页面 10 | * 11 | * @param props 内容组件props 12 | */ 13 | const useHighlight = (props: ContentPreviewProps) => { 14 | const { highlight } = useContext(EditorContext); 15 | 16 | // hljs是否已经提供 17 | const hljsRef = useRef(globalConfig.editorExtensions.highlight!.instance); 18 | const [hljsInited, setHljsInited] = useState(!!hljsRef.current); 19 | 20 | useEffect(() => { 21 | // 强制不高亮,则什么都不做 22 | if (props.noHighlight || globalConfig.editorExtensions.highlight!.instance) { 23 | return; 24 | } 25 | 26 | updateHandler('link', { 27 | ...highlight.css, 28 | rel: 'stylesheet', 29 | id: CDN_IDS.hlcss 30 | }); 31 | }, [highlight.css, props.noHighlight]); 32 | 33 | useEffect(() => { 34 | // 强制不高亮,则什么都不做 35 | if (props.noHighlight || hljsRef.current) { 36 | return; 37 | } 38 | 39 | appendHandler( 40 | 'script', 41 | { 42 | ...highlight.js, 43 | id: CDN_IDS.hljs, 44 | onload() { 45 | hljsRef.current = window.hljs; 46 | setHljsInited(true); 47 | } 48 | }, 49 | 'hljs' 50 | ); 51 | 52 | // 只执行一次 53 | // eslint-disable-next-line react-hooks/exhaustive-deps 54 | }, []); 55 | 56 | return { hljsRef, hljsInited }; 57 | }; 58 | 59 | export default useHighlight; 60 | -------------------------------------------------------------------------------- /scripts/build.type.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import chalk from 'chalk'; 4 | import { replaceTscAliasPaths } from 'tsc-alias'; 5 | import ts from 'typescript'; 6 | 7 | export const buildType = () => { 8 | const configFile = 'tsconfig.build.json'; 9 | 10 | const configPath = path.resolve(configFile); 11 | const configFileContents = fs.readFileSync(configPath, 'utf8'); 12 | const config = ts.parseConfigFileTextToJson(configPath, configFileContents); 13 | 14 | const parsedCommandLine = ts.parseJsonConfigFileContent( 15 | config.config, 16 | ts.sys, 17 | path.dirname(configPath) 18 | ); 19 | 20 | const program = ts.createProgram( 21 | parsedCommandLine.fileNames, 22 | parsedCommandLine.options 23 | ); 24 | 25 | const emitResult = program.emit(); 26 | 27 | const allDiagnostics = ts.getPreEmitDiagnostics(program).concat(emitResult.diagnostics); 28 | 29 | allDiagnostics.forEach((diagnostic) => { 30 | if (diagnostic.file) { 31 | const { line, character } = diagnostic.file.getLineAndCharacterOfPosition( 32 | diagnostic.start 33 | ); 34 | const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'); 35 | console.log( 36 | chalk.red( 37 | `${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message}` 38 | ) 39 | ); 40 | } else { 41 | console.log( 42 | chalk.red(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')) 43 | ); 44 | } 45 | }); 46 | 47 | if (allDiagnostics.length > 0) { 48 | process.exit(1); 49 | } 50 | 51 | // 处理别名 52 | void replaceTscAliasPaths({ 53 | configFile: configPath 54 | }); 55 | }; 56 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Content/props.ts: -------------------------------------------------------------------------------- 1 | import { CompletionSource } from '@codemirror/autocomplete'; 2 | import { HeadList, MdHeadingId, PreviewRendererComponent, SettingType } from '~/type'; 3 | 4 | export interface ContentPreviewProps { 5 | modelValue: string; 6 | onChange: (v: string) => void; 7 | setting?: SettingType; 8 | onHtmlChanged?: (h: string) => void; 9 | onGetCatalog?: (list: HeadList[]) => void; 10 | mdHeadingId: MdHeadingId; 11 | noMermaid?: boolean; 12 | sanitize: (html: string) => string; 13 | noKatex?: boolean; 14 | formatCopiedText?: (text: string) => string; 15 | noHighlight?: boolean; 16 | previewOnly?: boolean; 17 | noImgZoomIn?: boolean; 18 | sanitizeMermaid: (html: string) => Promise; 19 | codeFoldable: boolean; 20 | autoFoldThreshold: number; 21 | onRemount?: () => void; 22 | noEcharts?: boolean; 23 | previewComponent?: PreviewRendererComponent; 24 | } 25 | 26 | export type ContentProps = Readonly< 27 | { 28 | placeholder: string; 29 | scrollAuto: boolean; 30 | autoFocus?: boolean; 31 | readOnly?: boolean; 32 | maxLength?: number; 33 | autoDetectCode?: boolean; 34 | /** 35 | * 输入框失去焦点时触发事件 36 | */ 37 | onBlur?: (event: FocusEvent) => void; 38 | /** 39 | * 输入框获得焦点时触发事件 40 | */ 41 | onFocus?: (event: FocusEvent) => void; 42 | completions?: Array; 43 | onInput?: (e: Event) => void; 44 | /** 45 | * 拖放事件 46 | * 47 | * @param event 48 | * @returns 49 | */ 50 | onDrop?: (event: DragEvent) => void; 51 | inputBoxWidth: string; 52 | onInputBoxWidthChange?: (width: string) => void; 53 | transformImgUrl: (text: string) => string | Promise; 54 | catalogLayout?: 'fixed' | 'flat'; 55 | catalogMaxDepth?: number; 56 | } & ContentPreviewProps 57 | >; 58 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Content/hooks/useTaskState.ts: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect } from 'react'; 2 | import { EditorContext } from '~/context'; 3 | import { TASK_STATE_CHANGED } from '~/static/event-name'; 4 | import bus from '~/utils/event-bus'; 5 | import { ContentPreviewProps } from '../props'; 6 | 7 | const template = { 8 | checked: { 9 | regexp: /- \[x\]/, 10 | value: '- [ ]' 11 | }, 12 | unChecked: { 13 | regexp: /- \[\s\]/, 14 | value: '- [x]' 15 | } 16 | }; 17 | 18 | export const useTaskState = (props: ContentPreviewProps, html: string) => { 19 | const { editorId, rootRef } = useContext(EditorContext); 20 | 21 | useEffect(() => { 22 | const tasks = rootRef!.current?.querySelectorAll('.task-list-item.enabled') || []; 23 | 24 | const listener = (e: Event) => { 25 | e.preventDefault(); 26 | const nextValue = (e.target as HTMLInputElement).checked ? 'unChecked' : 'checked'; 27 | const line = (e.target as HTMLInputElement).parentElement?.dataset.line; 28 | 29 | if (!line) { 30 | return; 31 | } 32 | 33 | const lineNumber = Number(line); 34 | 35 | const lines = props.modelValue.split('\n'); 36 | const targetValue = lines[Number(lineNumber)].replace( 37 | template[nextValue].regexp, 38 | template[nextValue].value 39 | ); 40 | 41 | if (props.previewOnly) { 42 | lines[Number(lineNumber)] = targetValue; 43 | props.onChange(lines.join('\n')); 44 | } else { 45 | bus.emit(editorId, TASK_STATE_CHANGED, lineNumber + 1, targetValue); 46 | } 47 | }; 48 | 49 | tasks.forEach((item) => { 50 | item.addEventListener('click', listener); 51 | }); 52 | 53 | return () => { 54 | tasks.forEach((item) => { 55 | item.removeEventListener('click', listener); 56 | }); 57 | }; 58 | }, [editorId, html, props, rootRef]); 59 | }; 60 | -------------------------------------------------------------------------------- /packages/ModalToolbar/index.tsx: -------------------------------------------------------------------------------- 1 | import { CSSProperties, MouseEvent, ReactNode, memo, useCallback } from 'react'; 2 | import Modal from '~/components/Modal'; 3 | import { prefix } from '~/config'; 4 | 5 | export interface ModalToolbarProps { 6 | title?: string; 7 | modalTitle?: string; 8 | visible: boolean; 9 | width?: string; 10 | height?: string; 11 | trigger: ReactNode; 12 | onClick: (e: MouseEvent) => void; 13 | onClose: () => void; 14 | showAdjust?: boolean; 15 | isFullscreen?: boolean; 16 | onAdjust?: (v: boolean) => void; 17 | children?: any; 18 | className?: string; 19 | style?: CSSProperties; 20 | showMask?: boolean; 21 | disabled?: boolean; 22 | } 23 | 24 | const ModalToolbar = (props: ModalToolbarProps) => { 25 | const { width = 'auto', height = 'auto' } = props; 26 | 27 | const onAdjust = useCallback( 28 | (v: boolean) => { 29 | if (props.onAdjust instanceof Function) { 30 | props.onAdjust(v); 31 | } 32 | }, 33 | [props] 34 | ); 35 | 36 | return ( 37 | <> 38 | 49 | 62 | {props.children} 63 | 64 | 65 | ); 66 | }; 67 | 68 | export default memo(ModalToolbar); 69 | -------------------------------------------------------------------------------- /dev/Header/index.less: -------------------------------------------------------------------------------- 1 | @import '../vars'; 2 | 3 | .page-header { 4 | color: @colorReverse; 5 | padding: 1rem 6rem; 6 | text-align: center; 7 | background-color: #159957; 8 | background-image: linear-gradient(120deg, #155799, #159957); 9 | 10 | .header-actions { 11 | margin-bottom: 10px; 12 | 13 | .btn-header { 14 | font-size: 1rem; 15 | font-family: inherit; 16 | color: @colorReverse; 17 | line-height: 1.5; 18 | background: transparent; 19 | height: auto; 20 | padding: 0.5rem 1rem; 21 | border-color: @borderColorReverse; 22 | 23 | &:hover { 24 | border-color: @borderColor; 25 | } 26 | 27 | a { 28 | text-decoration: none; 29 | color: inherit; 30 | 31 | &:visited { 32 | color: inherit; 33 | } 34 | } 35 | } 36 | } 37 | } 38 | 39 | .theme-dark { 40 | .page-header { 41 | color: @colorDark; 42 | background-color: @bkColorDark; 43 | // background-image: linear-gradient(120deg, #051423, #041b0f); 44 | background-image: none; 45 | border-bottom: 1px solid @borderColorDark; 46 | 47 | .project-name { 48 | font-size: 3.25rem; 49 | margin: 0.5rem; 50 | } 51 | 52 | .project-desc { 53 | font-size: 1.25rem; 54 | padding: 1rem; 55 | } 56 | 57 | .header-actions { 58 | .btn-header { 59 | font-size: 1rem; 60 | font-family: inherit; 61 | color: @colorReverse; 62 | line-height: 1.5; 63 | background: transparent; 64 | height: auto; 65 | padding: 0.5rem 1rem; 66 | border-color: @borderColorDark; 67 | opacity: 0.7; 68 | 69 | &:hover { 70 | border-color: @borderColor; 71 | } 72 | 73 | a { 74 | text-decoration: none; 75 | 76 | &:visited { 77 | color: inherit; 78 | } 79 | } 80 | } 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Content/index.less: -------------------------------------------------------------------------------- 1 | .@{prefix} { 2 | &-content { 3 | position: relative; 4 | display: flex; 5 | // height: calc(100% - @toolBarHeight - 2 * @toolBarPadding - 1px); 6 | flex: 1; 7 | height: 0; 8 | flex-shrink: 0; 9 | 10 | &-wrapper { 11 | display: flex; 12 | flex: 1; 13 | width: 0; 14 | position: relative; 15 | } 16 | } 17 | 18 | &-resize-operate { 19 | position: absolute; 20 | width: 2px; 21 | height: 100%; 22 | background-color: var(--md-bk-color); 23 | z-index: 1; 24 | cursor: col-resize; 25 | } 26 | 27 | &-input-wrapper { 28 | height: 100%; 29 | box-sizing: border-box; 30 | } 31 | 32 | &-preview-wrapper { 33 | position: relative; 34 | // flex: 1; 35 | height: 100%; 36 | box-sizing: border-box; 37 | overflow: auto; 38 | 39 | // Firefox 40 | scrollbar-width: none; 41 | 42 | &::-webkit-scrollbar { 43 | // Chrome/Safari 44 | display: none; 45 | } 46 | } 47 | 48 | &-html { 49 | font-size: 16px; 50 | word-break: break-all; 51 | } 52 | } 53 | 54 | .@{prefix}-catalog-editor { 55 | // .css-vars(false); 56 | position: relative; 57 | overflow-x: hidden; 58 | overflow-y: auto; 59 | height: 100%; 60 | background-color: var(--md-bk-color); 61 | border-left: 1px solid var(--md-border-color); 62 | width: 200px; 63 | box-sizing: border-box; 64 | margin: 0; 65 | padding: 5px 10px; 66 | font-size: 14px; 67 | font-variant: tabular-nums; 68 | line-height: 1.5; 69 | list-style: none; 70 | font-feature-settings: 'tnum'; 71 | 72 | // Firefox 73 | scrollbar-width: none; 74 | 75 | &::-webkit-scrollbar { 76 | // Chrome/Safari 77 | display: none; 78 | } 79 | } 80 | 81 | .@{prefix}-catalog-fixed { 82 | position: absolute; 83 | top: 0; 84 | right: 0; 85 | z-index: 10002; 86 | } 87 | 88 | .@{prefix}-catalog-flat { 89 | position: initial; 90 | flex-shrink: 0; 91 | } 92 | -------------------------------------------------------------------------------- /example/web-component/src/MdEditorElement/data.md: -------------------------------------------------------------------------------- 1 | ## 1. md-editor-v3 2 | 3 | ![](https://img.shields.io/github/package-json/v/imzbf/md-editor-v3) ![](https://img.shields.io/npm/dm/md-editor-v3) ![](https://img.shields.io/bundlephobia/min/md-editor-v3) ![](https://img.shields.io/github/license/imzbf/md-editor-v3) ![](https://img.shields.io/badge/ssr-%3E1.6.0-brightgreen) 4 | 5 | Markdown 编辑器,基于 react,使用 jsx 和 typescript 语法开发,支持切换主题、prettier 美化文本等。 6 | 7 | ### 1.1 基本演示 8 | 9 | **加粗**,下划线,_斜体_,~删除线~,上标26,下标[1],`inline code`,[超链接](https://github.com/imzbf) 10 | 11 | 1. 打开冰箱 12 | 2. 钻进去 13 | 3. 关闭冰箱 14 | 15 | - 打开冰箱 16 | - 钻出来 17 | - 关闭冰箱 18 | 19 | - [x] 打开冰箱 20 | - [ ] 关闭冰箱 21 | 22 | > 引用:这是一段文本引用 23 | 24 | ![alt](https://imzbf.github.io/md-editor-v3/imgs/preview-light.png 'title') 25 | 26 | ## 2. 代码演示 27 | 28 | ```js 29 | import { defineComponent, ref } from 'vue'; 30 | import MdEditor from 'md-editor-v3'; 31 | import 'md-editor-v3/lib/style.css'; 32 | 33 | export default defineComponent({ 34 | name: 'MdEditor', 35 | setup() { 36 | const text = ref(''); 37 | return () => ( 38 | (text.value = v)} /> 39 | ); 40 | } 41 | }); 42 | ``` 43 | 44 | ```shell [install:yarn] 45 | yarn add md-editor-v3 46 | ``` 47 | 48 | ```shell [install:npm] 49 | npm i md-editor-v3 50 | ``` 51 | 52 | ## 3. 文本演示 53 | 54 | 依照普朗克长度这项单位,目前可观测的宇宙的直径估计值(直径约 930 亿光年,即 8.8 × 1026 米)即为 5.4 × 1061倍普朗克长度。而可观测宇宙体积则为 8.4 × 10184立方普朗克长度(普朗克体积)。 55 | 56 | ## 4. 表格演示 57 | 58 | | 昵称 | 猿龄(年) | 来自 | 59 | | ---- | ---------- | --------- | 60 | | 之间 | ∞ | 中国-重庆 | 61 | 62 | ## 5. 数学公式 63 | 64 | $$ 65 | \begin{equation} 66 | a^2+b^2=c^2 67 | \end{equation} 68 | $$ 69 | 70 | ## 6. 图形 71 | 72 | ```mermaid 73 | flowchart TD 74 | Start --> Stop 75 | ``` 76 | 77 | ## 7. 占个坑@! 78 | 79 | !!! note 支持的类型 80 | 81 | note、abstract、info、tip、success、question、warning、failure、danger、bug、example、quote、hint、caution、error、attention 82 | 83 | !!! 84 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Toolbar/tools/Table.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useCallback, useContext, useMemo, useState } from 'react'; 2 | import DropDown from '~/components/Dropdown'; 3 | import Icon from '~/components/Icon'; 4 | import { prefix } from '~/config'; 5 | import { EditorContext } from '~/context'; 6 | import { REPLACE } from '~/static/event-name'; 7 | import { classnames } from '~/utils'; 8 | import bus from '~/utils/event-bus'; 9 | import TableShape, { HoverData } from '../TableShape'; 10 | 11 | const ToolbarTable = () => { 12 | const { 13 | editorId, 14 | usedLanguageText: ult, 15 | showToolbarName, 16 | disabled, 17 | tableShape 18 | } = useContext(EditorContext); 19 | const wrapperId = `${editorId}-toolbar-wrapper`; 20 | const [visible, setVisible] = useState(false); 21 | 22 | const onSelected = useCallback( 23 | (selectedShape: HoverData) => { 24 | if (disabled) return; 25 | bus.emit(editorId, REPLACE, 'table', { selectedShape }); 26 | }, 27 | [disabled, editorId] 28 | ); 29 | 30 | const overlay = useMemo(() => { 31 | return ; 32 | }, [onSelected, tableShape]); 33 | 34 | const child = useMemo(() => { 35 | return ( 36 | 50 | ); 51 | }, [disabled, showToolbarName, ult.toolbarTips?.table]); 52 | 53 | return ( 54 | 62 | {child} 63 | 64 | ); 65 | }; 66 | 67 | export default memo(ToolbarTable); 68 | -------------------------------------------------------------------------------- /packages/MdEditor/components/Icon/Str.ts: -------------------------------------------------------------------------------- 1 | import { prefix } from '~/config'; 2 | import { CustomIcon, CustomStrIcon } from '~/type'; 3 | 4 | const iconMaps: CustomStrIcon = { 5 | copy: ``, 6 | 'collapse-tips': ``, 7 | pin: ``, 8 | 'pin-off': ``, 9 | check: `` 10 | }; 11 | 12 | const StrIcon = (name: keyof CustomStrIcon, customIcon: CustomIcon): string => { 13 | if (typeof customIcon[name] === 'string') { 14 | return customIcon[name]; 15 | } 16 | 17 | return iconMaps[name]!; 18 | }; 19 | 20 | export default StrIcon; 21 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Content/ContentPreview.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useContext, useMemo } from 'react'; 2 | import { prefix } from '~/config'; 3 | import { EditorContext } from '~/context'; 4 | 5 | import { SettingType } from '~/type'; 6 | import { classnames } from '~/utils'; 7 | import { useCopyCode, useMarkdownIt, useZoom, useTaskState, useRemount } from './hooks'; 8 | import { ContentPreviewProps } from './props'; 9 | import UpdateOnDemand from './UpdateOnDemand'; 10 | 11 | const ContentPreview = (props: ContentPreviewProps) => { 12 | const { 13 | previewOnly = false, 14 | setting = { preview: true } as SettingType, 15 | previewComponent: PreviewComponent = UpdateOnDemand 16 | } = props; 17 | const { editorId, previewTheme, showCodeRowNumber } = useContext(EditorContext); 18 | 19 | // markdown => html 20 | const { html, key } = useMarkdownIt(props, !!previewOnly); 21 | // 复制代码 22 | useCopyCode(props, html, key); 23 | // 图片点击放大 24 | useZoom(props, html); 25 | // 任务状态 26 | useTaskState(props, html); 27 | // 标准的重新渲染事件,能够正确获取到html 28 | useRemount(props, html, key); 29 | 30 | const previewNode = useMemo(() => { 31 | return ( 32 | 42 | ); 43 | }, [PreviewComponent, editorId, html, key, previewTheme, showCodeRowNumber]); 44 | 45 | return ( 46 | <> 47 | {setting.preview && 48 | (previewOnly ? ( 49 | previewNode 50 | ) : ( 51 |
56 | {previewNode} 57 |
58 | ))} 59 | {setting.htmlPreview && ( 60 |
65 |
{html}
66 |
67 | )} 68 | 69 | ); 70 | }; 71 | 72 | export default memo(ContentPreview); 73 | -------------------------------------------------------------------------------- /dev/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState, StrictMode } from 'react'; 2 | import Header from './Header'; 3 | import Preview from './Preview'; 4 | import PreviewOnly from './PreviewOnly'; 5 | import './style.less'; 6 | import SecEditor from './SecEditor'; 7 | import StreamDemo from './StreamDemo'; 8 | 9 | export type Theme = 'dark' | 'light'; 10 | 11 | function App() { 12 | const [theme, setTheme] = useState( 13 | () => (localStorage.getItem('theme') as Theme) || 'light' 14 | ); 15 | const [previewTheme, setPreviewTheme] = useState( 16 | () => localStorage.getItem('previewTheme') || 'default' 17 | ); 18 | const [codeTheme, setCodeTheme] = useState( 19 | () => localStorage.getItem('codeTheme') || 'atom' 20 | ); 21 | const [lang, setLang] = useState<'zh-CN' | 'en-US'>( 22 | () => (localStorage.getItem('lang') as 'zh-CN' | 'en-US') || 'zh-CN' 23 | ); 24 | 25 | useEffect(() => { 26 | localStorage.setItem('theme', theme); 27 | localStorage.setItem('previewTheme', previewTheme); 28 | localStorage.setItem('codeTheme', codeTheme); 29 | localStorage.setItem('lang', lang); 30 | }, [codeTheme, lang, previewTheme, theme]); 31 | 32 | useEffect(() => { 33 | document.body.setAttribute('class', theme === 'dark' ? 'theme-dark' : 'theme-light'); 34 | }, [theme]); 35 | 36 | return ( 37 | 38 |
39 |
46 |
47 | 53 | 54 | 60 | 66 |
67 |
68 |
69 | ); 70 | } 71 | 72 | export default App; 73 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Content/hooks/useAutoScroll.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useContext, useEffect, useState } from 'react'; 2 | import { EditorContext } from '~/context'; 3 | import scrollAuto, { scrollAutoWithScale } from '~/utils/scroll-auto'; 4 | import CodeMirrorUt from '../codemirror'; 5 | import { ContentProps } from '../props'; 6 | 7 | /** 8 | * 自动滚动 9 | * 10 | * @param props 11 | * @param html 12 | * @param previewRef 13 | * @param htmlRef 14 | * @param codeMirrorUt 15 | */ 16 | const useAutoScroll = ( 17 | props: ContentProps, 18 | html: string, 19 | codeMirrorUt: RefObject 20 | ) => { 21 | const { editorId, setting } = useContext(EditorContext); 22 | const [scrollCb, setScrollCb] = useState({ 23 | clear() {}, 24 | init() {} 25 | }); 26 | 27 | // 更新完毕后判断是否需要重新绑定滚动事件 28 | useEffect(() => { 29 | const rootNode = codeMirrorUt.current?.view.contentDOM.getRootNode() as 30 | | Document 31 | | ShadowRoot; 32 | const cmScroller = rootNode?.querySelector( 33 | `#${editorId} .cm-scroller` 34 | ); 35 | 36 | const previewEle = rootNode?.querySelector( 37 | `[id="${editorId}-preview-wrapper"]` 38 | ); 39 | const htmlEle = rootNode?.querySelector( 40 | `[id="${editorId}-html-wrapper"]` 41 | ); 42 | 43 | if (previewEle || htmlEle) { 44 | const scrollHandler = previewEle ? scrollAuto : scrollAutoWithScale; 45 | const cEle = previewEle || htmlEle; 46 | 47 | const [init, clear] = scrollHandler(cmScroller!, cEle!, codeMirrorUt.current!); 48 | 49 | setScrollCb({ 50 | init, 51 | clear 52 | }); 53 | } 54 | }, [ 55 | html, 56 | setting.fullscreen, 57 | setting.pageFullscreen, 58 | setting.preview, 59 | setting.htmlPreview, 60 | editorId, 61 | codeMirrorUt 62 | ]); 63 | 64 | useEffect(() => { 65 | if ( 66 | props.scrollAuto && 67 | !setting.previewOnly && 68 | (setting.preview || setting.htmlPreview) 69 | ) { 70 | scrollCb.init(); 71 | } else { 72 | scrollCb.clear(); 73 | } 74 | 75 | return () => { 76 | scrollCb.clear(); 77 | }; 78 | }, [ 79 | scrollCb, 80 | props.scrollAuto, 81 | setting.preview, 82 | setting.htmlPreview, 83 | setting.previewOnly 84 | ]); 85 | }; 86 | 87 | export default useAutoScroll; 88 | -------------------------------------------------------------------------------- /packages/MdEditor/styles/preview.less: -------------------------------------------------------------------------------- 1 | // 部分样式来源于其他开源UI库 2 | @import '@vavt/markdown-theme/css/all.css'; 3 | @import '~/styles/vars.less'; 4 | @import '~~/MdCatalog/index.less'; 5 | 6 | .basic-style() { 7 | color: var(--md-color); 8 | font-family: 9 | -apple-system, BlinkMacSystemFont, 'Segoe UI Variable', 'Segoe UI', system-ui, 10 | ui-sans-serif, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji'; 11 | } 12 | 13 | .@{prefix} { 14 | .css-vars(false); 15 | .basic-style(); 16 | 17 | width: 100%; 18 | height: 500px; 19 | position: relative; 20 | box-sizing: border-box; 21 | border: 1px solid var(--md-border-color); 22 | display: flex; 23 | flex-direction: column; 24 | overflow: hidden; 25 | background-color: var(--md-bk-color); 26 | 27 | .@{prefix}-fullscreen { 28 | position: fixed !important; 29 | top: 0; 30 | right: 0; 31 | bottom: 0; 32 | left: 0; 33 | width: auto !important; 34 | height: auto !important; 35 | z-index: 10000; 36 | } 37 | 38 | svg&-icon { 39 | width: 16px; 40 | height: 16px; 41 | padding: 4px; 42 | fill: none; 43 | overflow: hidden; 44 | display: block; 45 | box-sizing: content-box; 46 | } 47 | 48 | // 特殊处理这三个图标大小 49 | .lucide-list, 50 | .lucide-list-ordered, 51 | .lucide-list-todo { 52 | width: 18px; 53 | height: 18px; 54 | padding: 3px; 55 | } 56 | 57 | &-preview { 58 | font-size: 16px; 59 | word-break: break-all; 60 | // 需要设置为bfc,父级才能正确获取到滚动高度 61 | display: flow-root; 62 | padding: 10px 20px; 63 | } 64 | } 65 | 66 | .@{prefix}-modal-container { 67 | .css-vars(false); 68 | .basic-style(); 69 | 70 | .lucide-x { 71 | width: 20px; 72 | height: 20px; 73 | padding: 2px; 74 | } 75 | } 76 | 77 | .@{prefix}-previewOnly { 78 | border: none; 79 | height: auto; 80 | overflow: visible; 81 | 82 | .@{prefix}-content { 83 | height: 100%; 84 | } 85 | 86 | .@{prefix}-preview { 87 | padding: 0; 88 | } 89 | 90 | .@{prefix}-preview-wrapper { 91 | overflow: visible; 92 | } 93 | } 94 | 95 | .@{prefix}-dark, 96 | .@{prefix}-modal-container[data-theme='dark'] { 97 | .css-vars(true); 98 | } 99 | 100 | // 图片预览优先级高于编辑器,低于编辑器弹窗 101 | .medium-zoom-overlay, 102 | .medium-zoom-image--opened { 103 | z-index: 100001; 104 | } 105 | -------------------------------------------------------------------------------- /dev/PreviewOnly/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useRef, useState } from 'react'; 2 | import { ExposePreviewParam, MdPreview, prefix } from '~~/index'; 3 | import { Theme } from '../App'; 4 | import mdText from '../data.md'; 5 | 6 | // import '~/styles/preview.less'; 7 | 8 | const editorId = 'preview-only-test'; 9 | 10 | interface PreviewOnlyProp { 11 | theme: Theme; 12 | previewTheme: string; 13 | codeTheme: string; 14 | lang: 'zh-CN' | 'en-US'; 15 | } 16 | 17 | const previewComponent = (props: { html: string; id: string; className: string }) => { 18 | return ( 19 |
24 | ); 25 | }; 26 | 27 | const PreviewOnly = (props: PreviewOnlyProp) => { 28 | const previewRef = useRef(null); 29 | 30 | const [value, setValue] = useState(mdText); 31 | 32 | const onRemount = useCallback(() => { 33 | document 34 | .querySelectorAll(`#${editorId} .${prefix}-preview .${prefix}-code`) 35 | .forEach((codeBlock: Element) => { 36 | const tools = codeBlock.querySelectorAll('.extra-code-tools'); 37 | tools.forEach((item) => { 38 | item.addEventListener('click', (e) => { 39 | e.preventDefault(); 40 | 41 | const activeCode = 42 | codeBlock.querySelector('input:checked + pre code') || 43 | codeBlock.querySelector('pre code'); 44 | 45 | const codeText = (activeCode as HTMLElement).textContent; 46 | 47 | console.log(codeText); 48 | }); 49 | }); 50 | }); 51 | }, []); 52 | 53 | return ( 54 |
55 | 62 |
63 | 76 |
77 |
78 | ); 79 | }; 80 | 81 | export default PreviewOnly; 82 | -------------------------------------------------------------------------------- /packages/MdEditor/components/Modal/index.less: -------------------------------------------------------------------------------- 1 | @maskZIndex: 20000; 2 | 3 | .@{prefix}-modal-mask { 4 | position: fixed; 5 | top: 0; 6 | right: 0; 7 | bottom: 0; 8 | left: 0; 9 | z-index: @maskZIndex; 10 | height: 100%; 11 | background-color: var(--md-modal-mask); 12 | } 13 | 14 | .@{prefix}-modal { 15 | display: block; 16 | background-color: var(--md-bk-color); 17 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 18 | 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; 19 | border-radius: 3px; 20 | border: 1px solid var(--md-border-color); 21 | position: fixed; 22 | z-index: @maskZIndex + 1; 23 | box-shadow: var(--md-modal-shadow); 24 | 25 | &-header { 26 | cursor: grab; 27 | display: flex; 28 | justify-content: space-between; 29 | padding: 10px 24px; 30 | color: var(--md-color); 31 | font-weight: 600; 32 | font-size: 16px; 33 | line-height: 22px; 34 | word-wrap: break-word; 35 | user-select: none; 36 | border-bottom: 1px solid var(--md-border-color); 37 | position: relative; 38 | } 39 | 40 | &-body { 41 | padding: 20px; 42 | font-size: 14px; 43 | word-wrap: break-word; 44 | height: calc(100% - 43px); 45 | box-sizing: border-box; 46 | } 47 | 48 | .@{prefix}-modal-func { 49 | position: absolute; 50 | top: 10px; 51 | right: 10px; 52 | 53 | .@{prefix}-modal-adjust, 54 | .@{prefix}-modal-close { 55 | cursor: pointer; 56 | width: 24px; 57 | height: 24px; 58 | line-height: 24px; 59 | text-align: center; 60 | display: inline-block; 61 | } 62 | 63 | .@{prefix}-modal-adjust { 64 | padding-right: 10px; 65 | } 66 | } 67 | } 68 | 69 | .animation { 70 | animation-duration: 0.15s; 71 | animation-fill-mode: forwards; 72 | } 73 | 74 | @keyframes zoomIn { 75 | from { 76 | opacity: 0; 77 | transform: scale3d(0.3, 0.3, 0.3); 78 | } 79 | 80 | 50% { 81 | opacity: 1; 82 | } 83 | } 84 | 85 | .zoom-in { 86 | animation-name: zoomIn; 87 | .animation(); 88 | } 89 | 90 | @keyframes zoomOut { 91 | from { 92 | opacity: 1; 93 | } 94 | 95 | 50% { 96 | opacity: 0; 97 | transform: scale3d(0.3, 0.3, 0.3); 98 | } 99 | 100 | to { 101 | opacity: 0; 102 | } 103 | } 104 | 105 | .zoom-out { 106 | animation-name: zoomOut; 107 | .animation(); 108 | } 109 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Toolbar/index.tsx: -------------------------------------------------------------------------------- 1 | import { draggingScroll } from '@vavt/util'; 2 | import { useContext, useMemo, useRef, useState, useEffect, memo } from 'react'; 3 | import { prefix } from '~/config'; 4 | import { EditorContext } from '~/context'; 5 | import { ToolbarNames } from '~/type'; 6 | import { classnames } from '~/utils'; 7 | 8 | import { useBarRender } from './hooks'; 9 | 10 | export interface ToolbarProps { 11 | // 工具栏选择显示 12 | toolbars: Array; 13 | // 工具栏选择不显示 14 | toolbarsExclude: Array; 15 | } 16 | 17 | const Toolbar = (props: ToolbarProps) => { 18 | const { toolbars, toolbarsExclude } = props; 19 | // 获取ID,语言设置 20 | const { editorId, showToolbarName } = useContext(EditorContext); 21 | 22 | const [wrapperId] = useState(() => `${editorId}-toolbar-wrapper`); 23 | const wrapperRef = useRef(null); 24 | 25 | const { barRender } = useBarRender(); 26 | 27 | // 通过'='分割左右 28 | const splitedbar = useMemo(() => { 29 | const excluedBars = toolbars.filter((barItem) => !toolbarsExclude.includes(barItem)); 30 | const moduleSplitIndex = excluedBars.indexOf('='); 31 | 32 | // 左侧部分 33 | const barLeft = 34 | moduleSplitIndex === -1 ? excluedBars : excluedBars.slice(0, moduleSplitIndex + 1); 35 | 36 | const barRight = 37 | moduleSplitIndex === -1 38 | ? [] 39 | : excluedBars.slice(moduleSplitIndex, Number.MAX_SAFE_INTEGER); 40 | 41 | return [ 42 | barLeft.map((barItem, idx) => barRender(barItem, `left-${idx}`)), 43 | barRight.map((barItem, idx) => barRender(barItem, `right-${idx}`)) 44 | ]; 45 | }, [toolbars, toolbarsExclude, barRender]); 46 | 47 | useEffect(() => { 48 | let rl = () => {}; 49 | 50 | if (wrapperRef.current) { 51 | rl = draggingScroll(wrapperRef.current); 52 | } 53 | 54 | return () => { 55 | rl(); 56 | }; 57 | }, [toolbars]); 58 | 59 | return ( 60 | <> 61 | {toolbars.length > 0 && ( 62 |
63 |
69 |
{splitedbar[0]}
70 |
{splitedbar[1]}
71 |
72 |
73 | )} 74 | 75 | ); 76 | }; 77 | 78 | export default memo(Toolbar); 79 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Content/markdownIt/xss/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 重写markdown-it-xss,它的作者好像不维护了 3 | */ 4 | 5 | // 为了不与之前的配置冲突,目前采用全量引入 6 | import markdownit from 'markdown-it'; 7 | import * as xss from 'xss'; 8 | 9 | export interface XSSPluginType { 10 | // https://github.com/leizongmin/js-xss/blob/master/README.zh.md 11 | xss?: (_xss: typeof xss) => XSS.IFilterXSSOptions | xss.IFilterXSSOptions; 12 | /** 13 | * 它不会覆盖默认的白名单,而是把默认白名单、内置白名单结合 14 | */ 15 | extendedWhiteList?: xss.IFilterXSSOptions['whiteList']; 16 | } 17 | 18 | const MdWhiteList: xss.IFilterXSSOptions['whiteList'] = { 19 | img: ['class'], 20 | // 支持任务列表 21 | input: ['class', 'disabled', 'type', 'checked'], 22 | // 主要支持youtobe、腾讯视频、哔哩哔哩等内嵌视频代码 23 | iframe: [ 24 | 'class', 25 | 'width', 26 | 'height', 27 | 'src', 28 | 'title', 29 | 'border', 30 | 'frameborder', 31 | 'framespacing', 32 | 'allow', 33 | 'allowfullscreen' 34 | ] 35 | }; 36 | 37 | export const XSSPlugin = (md: markdownit, options: XSSPluginType) => { 38 | const { extendedWhiteList = {}, xss: xssOption = {} } = options; 39 | let xssIns: xss.FilterXSS; 40 | 41 | if (typeof xssOption === 'function') { 42 | xssIns = new xss.FilterXSS(xssOption(xss) as xss.IFilterXSSOptions); 43 | } else { 44 | const whiteList = xss.getDefaultWhiteList(); 45 | 46 | // 把内置的和用户自定义的key拿出来,与默认的合并一下 47 | const keys = [...Object.keys(extendedWhiteList), ...Object.keys(MdWhiteList)]; 48 | 49 | keys.forEach((key) => { 50 | const xssWhiteItem = whiteList[key] || []; 51 | const innerWhiteItem = MdWhiteList[key] || []; 52 | const userDefWhiteItem = extendedWhiteList[key] || []; 53 | whiteList[key] = [ 54 | ...new Set([...xssWhiteItem, ...innerWhiteItem, ...userDefWhiteItem]) 55 | ]; 56 | }); 57 | 58 | xssIns = new xss.FilterXSS({ 59 | whiteList, 60 | // 自定义的优先级最高 61 | ...xssOption 62 | }); 63 | } 64 | 65 | md.core.ruler.after('linkify', 'xss', (state) => { 66 | for (let i = 0; i < state.tokens.length; i++) { 67 | const cur = state.tokens[i]; 68 | 69 | switch (cur.type) { 70 | case 'html_block': { 71 | cur.content = xssIns.process(cur.content); 72 | break; 73 | } 74 | 75 | case 'inline': { 76 | const inlineTokens = cur.children || []; 77 | 78 | inlineTokens.forEach((it) => { 79 | if (it.type === 'html_inline') { 80 | it.content = xssIns.process(it.content); 81 | } 82 | }); 83 | 84 | break; 85 | } 86 | } 87 | } 88 | }); 89 | }; 90 | -------------------------------------------------------------------------------- /dev/style.less: -------------------------------------------------------------------------------- 1 | @import './vars'; 2 | 3 | body, 4 | h1, 5 | h2, 6 | h3, 7 | h4, 8 | h5, 9 | h6, 10 | hr, 11 | p, 12 | blockquote, 13 | dl, 14 | dt, 15 | dd, 16 | ul, 17 | ol, 18 | li, 19 | pre, 20 | form, 21 | fieldset, 22 | legend, 23 | button, 24 | input, 25 | textarea, 26 | th, 27 | td { 28 | margin: 0; 29 | padding: 0; 30 | } 31 | 32 | body { 33 | font-size: 16px; 34 | font-family: 35 | 'Varela Round', 36 | 'Noto Sans SC', 37 | -apple-system, 38 | BlinkMacSystemFont, 39 | 'Segoe UI', 40 | Helvetica, 41 | Arial, 42 | sans-serif, 43 | 'Apple Color Emoji', 44 | 'Segoe UI Emoji', 45 | 'Segoe UI Symbol'; 46 | } 47 | 48 | /* 浏览器滚动条 */ 49 | ::-webkit-scrollbar { 50 | width: 10px; 51 | height: 10px; 52 | } 53 | 54 | ::-webkit-scrollbar-corner, 55 | ::-webkit-scrollbar-track { 56 | background-color: #e2e2e2; 57 | } 58 | 59 | ::-webkit-scrollbar-thumb { 60 | border-radius: 2px; 61 | background-color: rgba(0, 0, 0, 0.3); 62 | } 63 | 64 | ::-webkit-scrollbar-button:vertical { 65 | display: none; 66 | } 67 | 68 | ::-webkit-scrollbar-thumb:vertical:hover { 69 | background-color: rgba(0, 0, 0, 0.35); 70 | } 71 | 72 | ::-webkit-scrollbar-thumb:vertical:active { 73 | background-color: rgba(0, 0, 0, 0.38); 74 | } 75 | 76 | .container { 77 | width: 100%; 78 | max-width: 1170px; 79 | margin: 0 auto; 80 | position: relative; 81 | // overflow: auto; 82 | } 83 | 84 | .btn { 85 | font-weight: 400; 86 | text-align: center; 87 | vertical-align: middle; 88 | cursor: pointer; 89 | border: 1px solid transparent; 90 | white-space: nowrap; 91 | user-select: none; 92 | height: 32px; 93 | padding: 0 15px; 94 | font-size: 14px; 95 | border-radius: 4px; 96 | transition: all 0.2s linear; 97 | color: #515a6e; 98 | background-color: @bkColor; 99 | border-color: @borderColor; 100 | margin-left: 10px; 101 | 102 | &:first-of-type { 103 | margin-left: 0; 104 | } 105 | 106 | &:hover { 107 | color: #57a3f3; 108 | background-color: @bkColor; 109 | border-color: #57a3f3; 110 | } 111 | } 112 | 113 | .app { 114 | color: @color; 115 | // background-color: @bkColor; 116 | } 117 | 118 | .theme-dark { 119 | color-scheme: dark; 120 | color: @colorDark; 121 | background-color: @bkColorDark; 122 | 123 | .preview-actions { 124 | button { 125 | color: @colorDark; 126 | background-color: @bkColorDark; 127 | border-color: @borderColorDark; 128 | 129 | &:hover { 130 | background-color: @bkColorDark; 131 | } 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Content/hooks/useCopyCode.ts: -------------------------------------------------------------------------------- 1 | import copy2clipboard from '@vavt/copy2clipboard'; 2 | import { useContext, useEffect } from 'react'; 3 | import { prefix } from '~/config'; 4 | import { EditorContext } from '~/context'; 5 | import { ContentPreviewProps } from '../props'; 6 | 7 | const useCopyCode = (props: ContentPreviewProps, html: string, key: string) => { 8 | const { editorId, usedLanguageText, customIcon, rootRef, setting } = 9 | useContext(EditorContext); 10 | const { formatCopiedText = (t: string) => t } = props; 11 | 12 | useEffect(() => { 13 | if (setting.preview) { 14 | // 重新设置复制按钮 15 | rootRef!.current 16 | ?.querySelectorAll(`#${editorId} .${prefix}-preview .${prefix}-code`) 17 | .forEach((codeBlock: Element) => { 18 | // 恢复进程ID 19 | let clearTimer = -1; 20 | 21 | const copyButton = codeBlock.querySelector( 22 | `.${prefix}-copy-button:not([data-processed])` 23 | ); 24 | 25 | if (copyButton) { 26 | copyButton.onclick = (e) => { 27 | e.preventDefault(); 28 | // 多次点击移除上次的恢复进程 29 | clearTimeout(clearTimer); 30 | 31 | const activeCode = 32 | codeBlock.querySelector('input:checked + pre code') || 33 | codeBlock.querySelector('pre code'); 34 | 35 | const codeText = (activeCode as HTMLElement).textContent || ''; 36 | const { text, successTips, failTips } = usedLanguageText.copyCode!; 37 | 38 | let msg = successTips!; 39 | 40 | copy2clipboard(formatCopiedText(codeText)) 41 | .catch(() => { 42 | msg = failTips!; 43 | }) 44 | .finally(() => { 45 | if (copyButton.dataset.isIcon) { 46 | copyButton.dataset.tips = msg; 47 | } else { 48 | copyButton.innerHTML = msg; 49 | } 50 | 51 | clearTimer = window.setTimeout(() => { 52 | if (copyButton.dataset.isIcon) { 53 | copyButton.dataset.tips = text; 54 | } else { 55 | copyButton.innerHTML = text!; 56 | } 57 | }, 1500); 58 | }); 59 | }; 60 | 61 | copyButton.setAttribute('data-processed', 'true'); 62 | } 63 | }); 64 | } 65 | }, [ 66 | customIcon, 67 | editorId, 68 | formatCopiedText, 69 | html, 70 | key, 71 | setting.preview, 72 | rootRef, 73 | usedLanguageText.copyCode 74 | ]); 75 | }; 76 | 77 | export default useCopyCode; 78 | -------------------------------------------------------------------------------- /packages/MdEditor/components/Icon/Icon.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Bold, 3 | ChartArea, 4 | Code, 5 | Expand, 6 | Eye, 7 | CodeXml, 8 | Forward, 9 | Heading, 10 | Image, 11 | Italic, 12 | Link, 13 | List, 14 | ListOrdered, 15 | ListTodo, 16 | ListTree, 17 | Maximize2, 18 | Minimize2, 19 | Quote, 20 | Reply, 21 | Save, 22 | Shrink, 23 | SquareCode, 24 | SquareSigma, 25 | Strikethrough, 26 | Subscript, 27 | Superscript, 28 | Table, 29 | Trash2, 30 | Underline, 31 | Upload, 32 | View, 33 | X, 34 | LucideProps 35 | } from 'lucide-react'; 36 | import { createElement, ForwardRefExoticComponent, memo } from 'react'; 37 | import { prefix } from '~/config'; 38 | 39 | import Github from './Github'; 40 | 41 | export type IconName = 42 | | 'bold' 43 | | 'underline' 44 | | 'italic' 45 | | 'strike-through' 46 | | 'title' 47 | | 'sub' 48 | | 'sup' 49 | | 'quote' 50 | | 'unordered-list' 51 | | 'ordered-list' 52 | | 'task' 53 | | 'code-row' 54 | | 'code' 55 | | 'link' 56 | | 'image' 57 | | 'table' 58 | | 'revoke' 59 | | 'next' 60 | | 'save' 61 | | 'prettier' 62 | | 'minimize' 63 | | 'maximize' 64 | | 'fullscreen-exit' 65 | | 'fullscreen' 66 | | 'preview-only' 67 | | 'preview' 68 | | 'preview-html' 69 | | 'catalog' 70 | | 'github' 71 | | 'mermaid' 72 | | 'formula' 73 | | 'close' 74 | | 'delete' 75 | | 'upload'; 76 | 77 | const iconMaps: { 78 | [key in IconName]: ForwardRefExoticComponent< 79 | Omit & React.RefAttributes 80 | >; 81 | } = { 82 | bold: Bold, 83 | underline: Underline, 84 | italic: Italic, 85 | 'strike-through': Strikethrough, 86 | title: Heading, 87 | sub: Subscript, 88 | sup: Superscript, 89 | quote: Quote, 90 | 'unordered-list': List, 91 | 'ordered-list': ListOrdered, 92 | task: ListTodo, 93 | 'code-row': Code, 94 | code: SquareCode, 95 | link: Link, 96 | image: Image, 97 | table: Table, 98 | revoke: Reply, 99 | next: Forward, 100 | save: Save, 101 | prettier: SquareCode, 102 | minimize: Minimize2, 103 | maximize: Maximize2, 104 | 'fullscreen-exit': Shrink, 105 | fullscreen: Expand, 106 | 'preview-only': View, 107 | preview: Eye, 108 | 'preview-html': CodeXml, 109 | catalog: ListTree, 110 | github: Github as any, 111 | mermaid: ChartArea, 112 | formula: SquareSigma, 113 | close: X, 114 | delete: Trash2, 115 | upload: Upload 116 | }; 117 | 118 | const Icon = (props: { name: IconName }) => { 119 | return createElement(iconMaps[props.name], { 120 | className: `${prefix}-icon` 121 | }); 122 | }; 123 | 124 | export default memo(Icon); 125 | -------------------------------------------------------------------------------- /dev/Header/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Theme } from '../App'; 3 | import './index.less'; 4 | 5 | interface HeaderProp { 6 | theme: Theme; 7 | onChange: (v: Theme) => void; 8 | onPreviewChange: (v: string) => void; 9 | onCodeThemeChange: (v: string) => void; 10 | onLangChange: (lang: 'zh-CN' | 'en-US') => void; 11 | } 12 | 13 | export default (props: HeaderProp) => ( 14 |
15 |
16 |

17 | 20 | 23 | 26 | 29 |

30 |

31 | 37 | 43 | 49 | 55 | 61 | 67 |

68 |

69 | {[ 70 | 'a11y', 71 | 'atom', 72 | 'github', 73 | 'gradient', 74 | 'kimbie', 75 | 'paraiso', 76 | 'qtcreator', 77 | 'stackoverflow' 78 | ].map((item) => { 79 | return ( 80 | 87 | ); 88 | })} 89 |

90 |
91 |
92 | ); 93 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Toolbar/tools/Katex.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useCallback, useContext, useMemo, useState } from 'react'; 2 | import DropDown from '~/components/Dropdown'; 3 | import Icon from '~/components/Icon'; 4 | import { prefix } from '~/config'; 5 | import { EditorContext } from '~/context'; 6 | import { REPLACE } from '~/static/event-name'; 7 | import { classnames } from '~/utils'; 8 | import { ToolDirective } from '~/utils/content-help'; 9 | import bus from '~/utils/event-bus'; 10 | 11 | const ToolbarKatex = () => { 12 | const { 13 | editorId, 14 | usedLanguageText: ult, 15 | showToolbarName, 16 | disabled 17 | } = useContext(EditorContext); 18 | const wrapperId = `${editorId}-toolbar-wrapper`; 19 | const [visible, setVisible] = useState(false); 20 | 21 | const emitHandler = useCallback( 22 | (direct: ToolDirective) => { 23 | if (disabled) return; 24 | 25 | bus.emit(editorId, REPLACE, direct); 26 | }, 27 | [disabled, editorId] 28 | ); 29 | 30 | const overlay = useMemo(() => { 31 | return ( 32 |
    { 35 | setVisible(false); 36 | }} 37 | role="menu" 38 | > 39 |
  • { 42 | emitHandler('katexInline'); 43 | }} 44 | role="menuitem" 45 | tabIndex={0} 46 | > 47 | {ult.katex?.inline} 48 |
  • 49 |
  • { 52 | emitHandler('katexBlock'); 53 | }} 54 | role="menuitem" 55 | tabIndex={0} 56 | > 57 | {ult.katex?.block} 58 |
  • 59 |
60 | ); 61 | }, [emitHandler, ult.katex?.block, ult.katex?.inline]); 62 | 63 | const child = useMemo(() => { 64 | return ( 65 | 79 | ); 80 | }, [disabled, showToolbarName, ult.toolbarTips?.katex]); 81 | 82 | return ( 83 | 91 | {child} 92 | 93 | ); 94 | }; 95 | 96 | export default memo(ToolbarKatex); 97 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js'; 2 | import { defineConfig } from 'eslint/config'; 3 | import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; 4 | import globals from 'globals'; 5 | import tseslint from 'typescript-eslint'; 6 | import importPlugin from 'eslint-plugin-import'; 7 | 8 | import reactHooks from 'eslint-plugin-react-hooks'; 9 | import reactRefresh from 'eslint-plugin-react-refresh'; 10 | 11 | export default defineConfig( 12 | { 13 | ignores: ['lib', 'eslint.config.mjs', '**/.local/**'] 14 | }, 15 | eslint.configs.recommended, 16 | ...tseslint.configs.recommendedTypeChecked, 17 | eslintPluginPrettierRecommended, 18 | { 19 | plugins: { 20 | import: importPlugin, 21 | 'react-hooks': reactHooks, 22 | 'react-refresh': reactRefresh 23 | }, 24 | languageOptions: { 25 | globals: { 26 | ...globals.node, 27 | ...globals.browser 28 | }, 29 | sourceType: 'module', 30 | parserOptions: { 31 | projectService: true, 32 | tsconfigRootDir: import.meta.dirname 33 | } 34 | }, 35 | settings: { 36 | 'import/resolver': { 37 | typescript: {} // 使用 tsconfig.json 中的 paths 字段 38 | } 39 | }, 40 | rules: { 41 | ...reactHooks.configs.recommended.rules, 42 | '@typescript-eslint/no-explicit-any': 'off', 43 | // 不强制所有函数必须显式声明返回类型 44 | '@typescript-eslint/explicit-function-return-type': 'off', 45 | // 不要求所有模块公有导出(函数、方法)必须显式声明参数与返回类型 46 | '@typescript-eslint/explicit-module-boundary-types': 'off', 47 | 48 | // 关闭“对 any 类型变量赋值”的限制(例如:const a: any = ...) 49 | // 在某些快速开发场景中可容忍此类不安全赋值 50 | '@typescript-eslint/no-unsafe-assignment': 'off', 51 | // 关闭“对 any 类型成员访问”的限制(例如:a.b.c) 52 | // 适用于对第三方库、全局变量等非类型安全场景的宽松处理 53 | '@typescript-eslint/no-unsafe-member-access': 'off', 54 | // 关闭“对 any 类型函数返回值”的限制(例如:function foo(): any { ... }) 55 | '@typescript-eslint/no-unsafe-return': 'off', 56 | // 关闭“对 any 类型函数调用”的限制(例如:anyFunc()) 57 | // 可减少类型不完整时的报错干扰,但需自行保证调用安全性 58 | '@typescript-eslint/no-unsafe-call': 'off', 59 | // 关闭“未绑定方法直接赋值”的限制(例如:const fn = obj.method) 60 | // 在某些 class 实例或函数绑定场景下更方便使用 61 | '@typescript-eslint/unbound-method': 'off', 62 | 63 | // import 顺序规则 64 | 'import/order': [ 65 | 'error', 66 | { 67 | groups: ['builtin', 'external', 'internal', ['parent', 'sibling', 'index']], 68 | pathGroups: [ 69 | { 70 | pattern: '@/**', 71 | group: 'internal', 72 | position: 'after' 73 | } 74 | ], 75 | pathGroupsExcludedImportTypes: ['builtin'], 76 | 'newlines-between': 'ignore', 77 | alphabetize: { 78 | order: 'asc', 79 | caseInsensitive: true 80 | } 81 | } 82 | ] 83 | } 84 | } 85 | ); 86 | -------------------------------------------------------------------------------- /README-CN.md: -------------------------------------------------------------------------------- 1 | # 🎄 md-editor-rt 2 | 3 | ![](https://img.shields.io/github/package-json/v/imzbf/md-editor-rt) ![](https://img.shields.io/npm/dm/md-editor-rt) ![](https://img.shields.io/github/license/imzbf/md-editor-rt) ![](https://img.shields.io/badge/ssr-%3E1.0.0-brightgreen) ![](https://img.shields.io/badge/webcomponent-%3E4.19.0-brightgreen) 4 | 5 | [English](https://github.com/imzbf/md-editor-rt) \| 中文 6 | 7 | `react`版本的 Markdown 编辑器,使用`jsx`和`typescript`开发。 8 | 9 | - 文档与在线预览:[传送门](https://imzbf.github.io/md-editor-rt) 10 | 11 | - [md-editor-v3](https://imzbf.github.io/md-editor-v3)同系列项目。 12 | 13 | ## ⭐️ 功能一览 14 | 15 | - 快捷插入内容工具栏、编辑器浏览器全屏、页面内全屏等。 16 | - 内置的白色主题和暗黑主题,支持绑定切换。 17 | - 支持快捷键插入内容; 支持使用 prettier 格式化内容(使用 CDN 方式引入,只支持格式化 md 内容,可在代码内设置关闭)。 18 | - 多语言,支持自行扩展语言。 19 | - 粘贴上传图片,图片裁剪上传。 20 | - 仅预览模式(不显示编辑器,只显示 md 预览内容,无额外监听)。 21 | - 预览主题,内置`default`、`vuepress`、`github` 、`cyanosis`、`mk-cute`、`smart-blue` 6 种预览主题(不完全相同),支持自定义主题(参考文档 demo 页示例)。 22 | - `mermaid`绘图(>=1.3.0),`katex`数学公式(>=1.4.0)。 23 | - 自定义工具栏顺序或显示,自定义扩展工具栏(支持点击类型、下拉菜单类型及弹窗类型)等。 24 | - 按需引用(>=4.0.0)。 25 | 26 | ## 🗺 预览图 27 | 28 | | 默认模式 | 暗黑模式 | 仅预览 | 29 | | --- | --- | --- | 30 | | ![默认模式](https://imzbf.github.io/md-editor-v3/imgs/preview-light.png) | ![暗黑模式](https://imzbf.github.io/md-editor-v3/imgs/preview-dark.png) | ![](https://imzbf.github.io/md-editor-v3/imgs/preview-previewOnly.png) | 31 | 32 | 输入提示和自定义简单的标记、表情扩展预览 33 | 34 | ![](https://imzbf.github.io/md-editor-v3/imgs/mark_emoji.gif) 35 | 36 | ## 📦 安装 37 | 38 | ```shell 39 | yarn add md-editor-rt 40 | ``` 41 | 42 | 使用已存在的语言、主题扩展,例如:日语 43 | 44 | ```shell 45 | yarn add @vavt/cm-extension 46 | ``` 47 | 48 | 使用更多的扩展工具栏组件,例如:导出内容为 PDF 49 | 50 | ```shell 51 | yarn add @vavt/rt-extension 52 | ``` 53 | 54 | 更多使用及贡献方式参考:[md-editor-extension](https://github.com/imzbf/md-editor-extension) 55 | 56 | ## 💡 用法 57 | 58 | 从`v4.0.0`开始,内部组件支持按需引用。 59 | 60 | ### ✍🏻 编辑器模式 61 | 62 | ```jsx 63 | import React, { useState } from 'react'; 64 | import { MdEditor } from 'md-editor-rt'; 65 | import 'md-editor-rt/lib/style.css'; 66 | 67 | export default () => { 68 | const [text, setText] = useState('# Hello Editor'); 69 | return ; 70 | }; 71 | ``` 72 | 73 | ### 📖 仅预览模式 74 | 75 | ```jsx 76 | import React, { useState } from 'react'; 77 | import { MdEditor, MdCatalog } from 'md-editor-rt'; 78 | import 'md-editor-rt/lib/preview.css'; 79 | 80 | const scrollElement = document.documentElement; 81 | 82 | export default () => { 83 | const [text] = useState('# Hello Editor'); 84 | const [id] = useState('preview-only'); 85 | 86 | return ( 87 | <> 88 | 89 | 90 | 91 | ); 92 | }; 93 | ``` 94 | 95 | 当使用服务端渲染时,`scrollElement`应该是字符类型,例:`html`、`body`、`#id`、`.class`。 96 | 97 | --- 98 | 99 | 更多用法请前往 [文档](https://imzbf.github.io/md-editor-rt)。 100 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Toolbar/index.less: -------------------------------------------------------------------------------- 1 | @toolBarPadding: 4px; 2 | 3 | .@{prefix} { 4 | &-menu { 5 | margin: 0; 6 | padding: 0; 7 | border-radius: 3px; 8 | border: 1px solid var(--md-border-color); 9 | background-color: inherit; 10 | 11 | &-item { 12 | list-style: none; 13 | font-size: 12px; 14 | color: var(--md-color); 15 | padding: 4px 10px; 16 | cursor: pointer; 17 | line-height: 16px; 18 | 19 | &:first-of-type { 20 | padding-top: 8px; 21 | } 22 | 23 | &:last-of-type { 24 | padding-bottom: 8px; 25 | } 26 | 27 | &:hover { 28 | background-color: var(--md-bk-hover-color); 29 | } 30 | } 31 | } 32 | 33 | &-table-shape { 34 | padding: 4px; 35 | border-radius: 3px; 36 | border: 1px solid var(--md-border-color); 37 | display: flex; 38 | flex-direction: column; 39 | 40 | &-row { 41 | display: flex; 42 | } 43 | 44 | &-col { 45 | padding: 2px; 46 | cursor: pointer; 47 | 48 | &-default { 49 | width: 16px; 50 | height: 16px; 51 | background-color: #e0e0e0; 52 | border-radius: 3px; 53 | transition: all 0.2s; 54 | } 55 | 56 | &-include { 57 | background-color: #aaa; 58 | } 59 | } 60 | } 61 | 62 | &-toolbar-wrapper { 63 | overflow-x: auto; 64 | overflow-y: hidden; 65 | scrollbar-width: none; 66 | flex-shrink: 0; 67 | padding: @toolBarPadding; 68 | border-bottom: 1px solid var(--md-border-color); 69 | 70 | &::-webkit-scrollbar { 71 | height: 0 !important; 72 | } 73 | } 74 | 75 | &-toolbar { 76 | display: flex; 77 | justify-content: space-between; 78 | align-items: center; 79 | box-sizing: content-box; 80 | 81 | &-item { 82 | color: var(--md-color); 83 | display: flex; 84 | flex-direction: column; 85 | align-items: center; 86 | margin: 0 2px; 87 | padding: 0 2px; 88 | transition: all 0.3s; 89 | border-radius: 3px; 90 | cursor: pointer; 91 | list-style: none; 92 | user-select: none; 93 | text-align: center; 94 | border: none; 95 | background-color: transparent; 96 | 97 | &-name { 98 | font-size: 12px; 99 | word-break: keep-all; 100 | white-space: nowrap; 101 | } 102 | 103 | &:not([disabled]):hover { 104 | background-color: var(--md-bk-color-outstand); 105 | } 106 | } 107 | 108 | &-active { 109 | background-color: var(--md-bk-color-outstand); 110 | } 111 | 112 | &-left, 113 | &-right { 114 | padding: 1px 0; 115 | display: flex; 116 | align-items: center; 117 | } 118 | } 119 | 120 | .@{prefix}-stn { 121 | .@{prefix}-toolbar-item { 122 | padding: 0 6px; 123 | } 124 | } 125 | } 126 | 127 | .@{prefix}-dark { 128 | .@{prefix}-table-shape { 129 | &-col { 130 | &-default { 131 | background-color: #222; 132 | } 133 | 134 | &-include { 135 | background-color: #555; 136 | } 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Footer/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | cloneElement, 3 | memo, 4 | ReactElement, 5 | useCallback, 6 | useContext, 7 | useMemo 8 | } from 'react'; 9 | import { allFooter, prefix } from '~/config'; 10 | import { EditorContext } from '~/context'; 11 | import { Footers } from '~/type'; 12 | 13 | import MarkdownTotal from './MarkdownTotal'; 14 | import ScrollAuto from './ScrollAuto'; 15 | 16 | interface FooterProps { 17 | modelValue: string; 18 | footers: Array; 19 | noScrollAuto: boolean; 20 | scrollAuto: boolean; 21 | onScrollAutoChange: (v: boolean) => void; 22 | defFooters: Array; 23 | } 24 | 25 | const Footer = (props: FooterProps) => { 26 | const { theme, language, disabled } = useContext(EditorContext); 27 | 28 | const footerRender = useCallback( 29 | (name: Footers) => { 30 | if (allFooter.includes(name)) { 31 | switch (name) { 32 | case 'markdownTotal': { 33 | return ; 34 | } 35 | case 'scrollSwitch': { 36 | return ( 37 | !props.noScrollAuto && ( 38 | 43 | ) 44 | ); 45 | } 46 | } 47 | } else { 48 | const defItem = props.defFooters[name as number] as ReactElement< 49 | any, 50 | React.FunctionComponent 51 | >; 52 | 53 | if (typeof defItem !== 'string') { 54 | const defItemCloned = cloneElement(defItem, { 55 | theme: defItem.props?.theme || theme, 56 | language: defItem.props?.language || language, 57 | disabled: defItem.props?.disabled || disabled 58 | }); 59 | 60 | return defItemCloned; 61 | } 62 | 63 | return defItem || ''; 64 | } 65 | }, 66 | [ 67 | props.modelValue, 68 | props.noScrollAuto, 69 | props.scrollAuto, 70 | props.onScrollAutoChange, 71 | props.defFooters, 72 | theme, 73 | language, 74 | disabled 75 | ] 76 | ); 77 | 78 | const [LeftFooter, RightFooter] = useMemo(() => { 79 | const moduleSplitIndex = props.footers.indexOf('='); 80 | 81 | // 左侧部分 82 | const barLeft = 83 | moduleSplitIndex === -1 ? props.footers : props.footers.slice(0, moduleSplitIndex); 84 | 85 | const barRight = 86 | moduleSplitIndex === -1 87 | ? [] 88 | : props.footers.slice(moduleSplitIndex, Number.MAX_SAFE_INTEGER); 89 | 90 | return [ 91 | barLeft.map((name) => footerRender(name)), 92 | barRight.map((name) => footerRender(name)) 93 | ]; 94 | }, [props.footers, footerRender]); 95 | 96 | return ( 97 |
98 |
{LeftFooter}
99 |
{RightFooter}
100 |
101 | ); 102 | }; 103 | 104 | export default memo(Footer); 105 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Content/UpdateOnDemand.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useEffect, useRef } from 'react'; 2 | import type { PreviewRendererProps } from '~/type'; 3 | 4 | // 将 HTML 字符串拆分为元素,返回第一层子节点(包括文本节点) 5 | const splitNodes = (html: string): ChildNode[] => { 6 | const parser = new DOMParser(); 7 | const doc = parser.parseFromString(html, 'text/html'); 8 | return Array.from(doc.body.childNodes); 9 | }; 10 | 11 | // 比较新旧节点是否相同 12 | const isSameNode = (newNode: ChildNode, currentNode: ChildNode) => { 13 | if (newNode.nodeType !== currentNode.nodeType) { 14 | return false; 15 | } 16 | 17 | if (newNode.nodeType === Node.TEXT_NODE || newNode.nodeType === Node.COMMENT_NODE) { 18 | return newNode.textContent === currentNode.textContent; 19 | } 20 | 21 | if (newNode.nodeType === Node.ELEMENT_NODE) { 22 | return (newNode as Element).outerHTML === (currentNode as Element).outerHTML; 23 | } 24 | 25 | return newNode.isEqualNode ? newNode.isEqualNode(currentNode) : false; 26 | }; 27 | 28 | const updateHtmlContent = ( 29 | container: HTMLElement, 30 | newNodes: ChildNode[], 31 | prevNodes: ChildNode[] 32 | ) => { 33 | const currentNodes = Array.from(container.childNodes); 34 | const minLength = Math.min(newNodes.length, prevNodes.length); 35 | 36 | let divergenceIndex = -1; 37 | 38 | for (let i = 0; i < minLength; i += 1) { 39 | if (!isSameNode(newNodes[i], prevNodes[i])) { 40 | divergenceIndex = i; 41 | break; 42 | } 43 | } 44 | 45 | if (divergenceIndex === -1) { 46 | if (prevNodes.length > newNodes.length) { 47 | divergenceIndex = newNodes.length; 48 | } else if (newNodes.length > prevNodes.length) { 49 | divergenceIndex = prevNodes.length; 50 | } else { 51 | return; 52 | } 53 | } 54 | 55 | const startRemove = Math.min(divergenceIndex, currentNodes.length); 56 | 57 | for (let i = currentNodes.length - 1; i >= startRemove; i -= 1) { 58 | currentNodes[i].remove(); 59 | } 60 | 61 | for (let i = divergenceIndex; i < newNodes.length; i += 1) { 62 | container.appendChild(newNodes[i].cloneNode(true)); 63 | } 64 | }; 65 | 66 | type UpdateOnDemandProps = PreviewRendererProps; 67 | 68 | const UpdateOnDemand: React.FC = ({ html, id, className }) => { 69 | const containerRef = useRef(null); 70 | // 永远缓存一份第一次的html,保证ssr正确 71 | const firstHtml = useRef<{ __html: string }>({ __html: html }); 72 | const prevHtmlRef = useRef(html); 73 | 74 | useEffect(() => { 75 | const container = containerRef.current; 76 | 77 | if (!container) { 78 | return; 79 | } 80 | 81 | const prevHtml = prevHtmlRef.current; 82 | 83 | if (prevHtml === html) { 84 | return; 85 | } 86 | 87 | const newNodes = splitNodes(html); 88 | const prevNodes = splitNodes(prevHtml); 89 | 90 | updateHtmlContent(container, newNodes, prevNodes); 91 | 92 | prevHtmlRef.current = html; 93 | }, [html]); 94 | 95 | return ( 96 |
102 | ); 103 | }; 104 | 105 | export default memo(UpdateOnDemand); 106 | -------------------------------------------------------------------------------- /packages/MdCatalog/CatalogLink.tsx: -------------------------------------------------------------------------------- 1 | import { memo, MouseEvent, useContext, useEffect, useRef } from 'react'; 2 | import { prefix } from '~/config'; 3 | import { MdHeadingId } from '~/type'; 4 | import { classnames } from '~/utils'; 5 | import { getComputedStyleNum } from '~/utils/scroll-auto'; 6 | 7 | import { CatalogContext } from './context'; 8 | import { TocItem } from './index'; 9 | 10 | export interface CatalogLinkProps { 11 | tocItem: TocItem; 12 | mdHeadingId: MdHeadingId; 13 | onActive: (tocItem: TocItem, ele: HTMLDivElement) => void; 14 | onClick?: (e: MouseEvent, t: TocItem) => void; 15 | scrollElementOffsetTop?: number; 16 | } 17 | 18 | const CatalogLink = ({ 19 | tocItem, 20 | mdHeadingId, 21 | onActive, 22 | onClick, 23 | scrollElementOffsetTop = 0 24 | }: CatalogLinkProps) => { 25 | const { scrollElementRef, rootNodeRef } = useContext(CatalogContext); 26 | 27 | const currRef = useRef(null); 28 | 29 | useEffect(() => { 30 | if (tocItem.active) { 31 | onActive(tocItem, currRef.current!); 32 | } 33 | }, [onActive, tocItem, tocItem.active]); 34 | 35 | return ( 36 |
{ 43 | e.stopPropagation(); 44 | onClick?.(e, tocItem); 45 | 46 | if (e.defaultPrevented) { 47 | return; 48 | } 49 | 50 | const id = mdHeadingId({ 51 | text: tocItem.text, 52 | level: tocItem.level, 53 | index: tocItem.index, 54 | currentToken: tocItem.currentToken, 55 | nextToken: tocItem.nextToken 56 | }); 57 | const targetHeadEle = rootNodeRef?.current!.getElementById(id); 58 | const scrollContainer = scrollElementRef?.current; 59 | 60 | if (targetHeadEle && scrollContainer) { 61 | let par = targetHeadEle.offsetParent as HTMLElement; 62 | let offsetTop = targetHeadEle.offsetTop; 63 | 64 | // 滚动容器包含父级offser标准元素 65 | if (scrollContainer.contains(par)) { 66 | while (par && scrollContainer != par) { 67 | // 循环获取当前对象与相对的top高度 68 | offsetTop += par?.offsetTop; 69 | par = par?.offsetParent as HTMLElement; 70 | } 71 | } 72 | 73 | const pel = targetHeadEle.previousElementSibling; 74 | let currMarginTop = 0; 75 | if (!pel) { 76 | currMarginTop = getComputedStyleNum(targetHeadEle, 'margin-top'); 77 | } 78 | 79 | scrollContainer?.scrollTo({ 80 | top: offsetTop - scrollElementOffsetTop - currMarginTop, 81 | behavior: 'smooth' 82 | }); 83 | } 84 | }} 85 | > 86 | {tocItem.text} 87 | {tocItem.children && tocItem.children.length > 0 && ( 88 |
89 | {tocItem.children.map((item) => ( 90 | 98 | ))} 99 |
100 | )} 101 |
102 | ); 103 | }; 104 | 105 | export default memo(CatalogLink); 106 | -------------------------------------------------------------------------------- /example/web-component/src/MdEditorElement/iconfont.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'md-editor-iconfont'; /* Project id 2605852 */ 3 | src: 4 | url('//at.alicdn.com/t/c/font_2605852_cmafimm6hot.woff2?t=1717482913093') 5 | format('woff2'), 6 | url('//at.alicdn.com/t/c/font_2605852_cmafimm6hot.woff?t=1717482913093') 7 | format('woff'), 8 | url('//at.alicdn.com/t/c/font_2605852_cmafimm6hot.ttf?t=1717482913093') 9 | format('truetype'); 10 | } 11 | 12 | .md-editor-iconfont { 13 | font-family: 'md-editor-iconfont' !important; 14 | font-size: 16px; 15 | font-style: normal; 16 | -webkit-font-smoothing: antialiased; 17 | -moz-osx-font-smoothing: grayscale; 18 | } 19 | 20 | .md-editor-icon-collapse-tips:before { 21 | content: '\e6c9'; 22 | } 23 | 24 | .md-editor-icon-preview-only:before { 25 | content: '\e601'; 26 | } 27 | 28 | .md-editor-icon-jump:before { 29 | content: '\e635'; 30 | } 31 | 32 | .md-editor-icon-copy:before { 33 | content: '\e63d'; 34 | } 35 | 36 | .md-editor-icon-task:before { 37 | content: '\eb16'; 38 | } 39 | 40 | .md-editor-icon-formula:before { 41 | content: '\e61f'; 42 | } 43 | 44 | .md-editor-icon-mermaid:before { 45 | content: '\e600'; 46 | } 47 | 48 | .md-editor-icon-layout-one:before { 49 | content: '\e647'; 50 | } 51 | 52 | .md-editor-icon-delete:before { 53 | content: '\e614'; 54 | } 55 | 56 | .md-editor-icon-upload:before { 57 | content: '\e696'; 58 | } 59 | 60 | .md-editor-icon-close:before { 61 | content: '\e631'; 62 | } 63 | 64 | .md-editor-icon-prettier:before { 65 | content: '\e7f8'; 66 | } 67 | 68 | .md-editor-icon-revoke:before { 69 | content: '\e60d'; 70 | } 71 | 72 | .md-editor-icon-next:before { 73 | content: '\e60f'; 74 | } 75 | 76 | .md-editor-icon-sup:before { 77 | content: '\ec83'; 78 | } 79 | 80 | .md-editor-icon-italic:before { 81 | content: '\ec85'; 82 | } 83 | 84 | .md-editor-icon-sub:before { 85 | content: '\ec86'; 86 | } 87 | 88 | .md-editor-icon-preview:before { 89 | content: '\e649'; 90 | } 91 | 92 | .md-editor-icon-coding:before { 93 | content: '\e6b0'; 94 | } 95 | 96 | .md-editor-icon-underline:before { 97 | content: '\eaef'; 98 | } 99 | 100 | .md-editor-icon-fangda:before { 101 | content: '\e6a7'; 102 | } 103 | 104 | .md-editor-icon-suoxiao:before { 105 | content: '\e6ab'; 106 | } 107 | 108 | .md-editor-icon-baocun:before { 109 | content: '\e644'; 110 | } 111 | 112 | .md-editor-icon-bold:before { 113 | content: '\e60e'; 114 | } 115 | 116 | .md-editor-icon-strike-through:before { 117 | content: '\e611'; 118 | } 119 | 120 | .md-editor-icon-link:before { 121 | content: '\e617'; 122 | } 123 | 124 | .md-editor-icon-ordered-list:before { 125 | content: '\e612'; 126 | } 127 | 128 | .md-editor-icon-unordered-list:before { 129 | content: '\e620'; 130 | } 131 | 132 | .md-editor-icon-code-row:before { 133 | content: '\e70b'; 134 | } 135 | 136 | .md-editor-icon-quote:before { 137 | content: '\e63c'; 138 | } 139 | 140 | .md-editor-icon-title:before { 141 | content: '\e61d'; 142 | } 143 | 144 | .md-editor-icon-github:before { 145 | content: '\e76f'; 146 | } 147 | 148 | .md-editor-icon-fullscreen-exit:before { 149 | content: '\e637'; 150 | } 151 | 152 | .md-editor-icon-fullscreen:before { 153 | content: '\e627'; 154 | } 155 | 156 | .md-editor-icon-code:before { 157 | content: '\f120'; 158 | } 159 | 160 | .md-editor-icon-catalog:before { 161 | content: '\e619'; 162 | } 163 | 164 | .md-editor-icon-table:before { 165 | content: '\e615'; 166 | } 167 | 168 | .md-editor-icon-image:before { 169 | content: '\e629'; 170 | } 171 | -------------------------------------------------------------------------------- /packages/MdEditor/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { prefix } from '../config'; 2 | 3 | /** 4 | * 转换base64为file对象 5 | * 方法来自网络 6 | * 7 | * @param base64 Base64 8 | * @param fileName 图片名称 9 | * @returns 10 | */ 11 | export const base642File = (base64: string, fileName = 'image.png') => { 12 | const arr = base64.split(','); 13 | const regResult = arr[0].match(/:(.*?);/); 14 | 15 | if (regResult) { 16 | const mime = regResult[1]; 17 | const bstr = atob(arr[1]); 18 | let n = bstr.length; 19 | const u8arr = new Uint8Array(n); 20 | while (n--) { 21 | u8arr[n] = bstr.charCodeAt(n); 22 | } 23 | 24 | return new File([u8arr], fileName, { type: mime }); 25 | } 26 | 27 | return null; 28 | }; 29 | 30 | /** 31 | * 对代码块添加行号 32 | * 33 | * @param code 代码html内容 34 | * @returns string 35 | */ 36 | export const generateCodeRowNumber = (code: string, sourceCode: string) => { 37 | if (!code) { 38 | return code; 39 | } 40 | 41 | const list = sourceCode.split('\n'); 42 | // 行号html代码拼接列表 43 | const rowNumberList = [''); 48 | return `${code}${rowNumberList.join('')}`; 49 | }; 50 | 51 | /** 52 | * 逻辑分离katex相关文本 53 | * 不再采用正确匹配,会导致性能问题 54 | * 55 | * @param str 待处理字符串 56 | * @param key 单行或多行标识符 57 | * @returns [] 58 | */ 59 | export const splitKatexValue = (str: string, key = '$'): Array => { 60 | const arr = str.split(key); 61 | let regText = key; 62 | let text = ''; 63 | 64 | for (let i = 1; i < arr.length; i++) { 65 | // 以\结尾的添加到文本中 66 | if (/\\$/.test(arr[i])) { 67 | regText += arr[i] + '$'; 68 | text += arr[i] + '$'; 69 | } else { 70 | regText += arr[i] + key; 71 | text += arr[i]; 72 | 73 | break; 74 | } 75 | } 76 | 77 | return [regText, text]; 78 | }; 79 | 80 | /** 81 | * 兼容firefox获取选中文本 82 | * 83 | * @param textarea 输入框element 84 | * @returns selectedText 85 | */ 86 | export const getSelectionText = (textarea: HTMLTextAreaElement): string => { 87 | const userAgent = navigator.userAgent; 88 | 89 | if (userAgent.indexOf('Firefox') > -1) { 90 | // firefox没法通过window.getSelection()?.toString()获取选中文本 91 | return textarea.value.substring(textarea.selectionStart, textarea.selectionEnd); 92 | } 93 | 94 | return window.getSelection()?.toString() || ''; 95 | }; 96 | 97 | /** 98 | * 列表类生成 99 | * 100 | * @param classList 待赛选class列表 101 | * @returns 102 | */ 103 | export const classnames = (classList: Array) => { 104 | return classList.filter(Boolean).join(' '); 105 | }; 106 | 107 | /** 108 | * 获取元素相对目标元素顶部位置 109 | * 代码来自antd 110 | * 111 | * @param element 112 | * @param container 113 | * @returns 114 | */ 115 | export const getRelativeTop = (element: HTMLElement, container: HTMLElement): number => { 116 | // 尝试移除元素不存在的潜在问题(https://github.com/imzbf/md-editor-v3/issues/308) 117 | if (!element || !container) { 118 | return 0; 119 | } 120 | 121 | const eleRect = element?.getBoundingClientRect(); 122 | 123 | if (container === document.documentElement) { 124 | return eleRect.top - container.clientTop; 125 | } 126 | 127 | const conRect = container?.getBoundingClientRect(); 128 | 129 | return eleRect.top - conRect.top; 130 | }; 131 | 132 | /** 133 | * 获取递增的zIndex 134 | */ 135 | export const getZIndexIncrement = (() => { 136 | let startIndex = 0; 137 | 138 | return () => { 139 | return ++startIndex; 140 | }; 141 | })(); 142 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🎄 md-editor-rt 2 | 3 | ![](https://img.shields.io/github/package-json/v/imzbf/md-editor-rt) ![](https://img.shields.io/npm/dm/md-editor-rt) ![](https://img.shields.io/github/license/imzbf/md-editor-rt) ![](https://img.shields.io/badge/ssr-%3E1.0.0-brightgreen) ![](https://img.shields.io/badge/webcomponent-%3E4.19.0-brightgreen) 4 | 5 | English \| [中文](https://github.com/imzbf/md-editor-rt/blob/develop/README-CN.md) 6 | 7 | Markdown editor for `react`, developed in `jsx` and `typescript`. 8 | 9 | - Documentation and demo:[Go](https://imzbf.github.io/md-editor-rt) 10 | 11 | - The same series editor for vue3:[md-editor-v3](https://github.com/imzbf/md-editor-v3) 12 | 13 | ## ⭐️ Features 14 | 15 | - Toolbar, screenfull or screenfull in web pages and so on. 16 | - Themes, Built-in default and dark themes. 17 | - Shortcut key for editor. 18 | - Beautify your content by `prettier`(only for markdown content, not the code and other text). 19 | - Multi-language, build-in Chinese and English(default: Chinese). 20 | - Upload picture, paste or clip the picture and upload it. 21 | - Render article directly(no editor, no event listener, only preview content). 22 | - Theme of preview, `default`, `vuepress`, `github`, `cyanosis`, `mk-cute`, `smart-blue` styles(not identical). It can be customized also(Refer to example page). 23 | - `mermaid`(>=1.3.0), `katex` mathematical formula(>=1.4.0). 24 | - Customize the toolbar as you like. 25 | - On-demand Import(>=4.0.0). 26 | 27 | ## 🗺 Preview 28 | 29 | | Default theme | Dark theme | Preview only | 30 | | --- | --- | --- | 31 | | ![](https://imzbf.github.io/md-editor-v3/imgs/preview-light.png) | ![](https://imzbf.github.io/md-editor-v3/imgs/preview-dark.png) | ![](https://imzbf.github.io/md-editor-v3/imgs/preview-previewOnly.png) | 32 | 33 | Inputing prompt and mark, emoji extensions 34 | 35 | ![](https://imzbf.github.io/md-editor-v3/imgs/mark_emoji.gif) 36 | 37 | ## 📦 Install 38 | 39 | ```shell 40 | yarn add md-editor-rt 41 | ``` 42 | 43 | Use existing extension of language and theme, such as Japanese 44 | 45 | ```shell 46 | yarn add @vavt/cm-extension 47 | ``` 48 | 49 | Use existing components of toolbar, such as exporting content as PDF 50 | 51 | ```shell 52 | yarn add @vavt/v3-extension 53 | ``` 54 | 55 | For more ways to use or contribute, please refer to: [md-editor-extension](https://github.com/imzbf/md-editor-extension) 56 | 57 | ## 💡 Usage 58 | 59 | Starting from `4.0.0`, internal components can be imported on-demand. 60 | 61 | ### ✍🏻 Display Editor 62 | 63 | ```jsx 64 | import React, { useState } from 'react'; 65 | import { MdEditor } from 'md-editor-rt'; 66 | import 'md-editor-rt/lib/style.css'; 67 | 68 | export default () => { 69 | const [text, setText] = useState('# Hello Editor'); 70 | return ; 71 | }; 72 | ``` 73 | 74 | ### 📖 Preview Only 75 | 76 | ```jsx 77 | import React, { useState } from 'react'; 78 | import { MdPreview, MdCatalog } from 'md-editor-rt'; 79 | import 'md-editor-rt/lib/preview.css'; 80 | 81 | const scrollElement = document.documentElement; 82 | 83 | export default () => { 84 | const [text] = useState('# Hello Editor'); 85 | const [id] = useState('preview-only'); 86 | 87 | return ( 88 | <> 89 | 90 | 91 | 92 | ); 93 | }; 94 | ``` 95 | 96 | When using server-side rendering, `scrollElement` should be of string type, eg: `html`, `body`, `#id`, `.class`. 97 | 98 | --- 99 | 100 | For more usage, please visit the [document](https://imzbf.github.io/md-editor-rt). 101 | -------------------------------------------------------------------------------- /scripts/build.ts: -------------------------------------------------------------------------------- 1 | import { rmSync } from 'fs'; 2 | import path from 'path'; 3 | import { fileURLToPath } from 'url'; 4 | import react from '@vitejs/plugin-react'; 5 | import { build, LibraryFormats } from 'vite'; 6 | import { buildType } from './build.type'; 7 | 8 | const __dirname = fileURLToPath(new URL('..', import.meta.url)); 9 | const resolvePath = (p: string) => path.resolve(__dirname, p); 10 | 11 | void (async () => { 12 | const moduleEntry = { 13 | index: resolvePath('packages'), 14 | MdEditor: resolvePath('packages/MdEditor'), 15 | MdPreview: resolvePath('packages/MdPreview'), 16 | NormalToolbar: resolvePath('packages/NormalToolbar'), 17 | DropdownToolbar: resolvePath('packages/DropdownToolbar'), 18 | ModalToolbar: resolvePath('packages/ModalToolbar'), 19 | MdCatalog: resolvePath('packages/MdCatalog'), 20 | MdModal: resolvePath('packages/MdEditor/components/Modal'), 21 | config: resolvePath('packages/config') 22 | }; 23 | 24 | const entries: Array<[LibraryFormats, any]> = [ 25 | [ 26 | 'es', 27 | { 28 | ...moduleEntry, 29 | // 这里只有利用vite构建的assetFileNames为文件名的特性构建样式文件 30 | preview: resolvePath('packages/MdEditor/styles/preview.less'), 31 | style: resolvePath('packages/MdEditor/styles/style.less') 32 | } 33 | ], 34 | ['cjs', moduleEntry] 35 | ]; 36 | 37 | const extnames = { 38 | es: 'mjs', 39 | cjs: 'cjs' 40 | }; 41 | 42 | rmSync(resolvePath('lib'), { recursive: true, force: true }); 43 | 44 | buildType(); 45 | 46 | await Promise.all( 47 | entries.map(([t, entry]) => { 48 | return build({ 49 | base: '/', 50 | publicDir: false, 51 | define: { 52 | // vite没有标记这个常理,在打包的时候,会将runtime-dev打包进去 53 | 'process.env.NODE_ENV': '"production"' 54 | }, 55 | resolve: { 56 | alias: { 57 | '~~': resolvePath('packages'), 58 | '~': resolvePath('packages/MdEditor') 59 | } 60 | }, 61 | plugins: [react()], 62 | css: { 63 | modules: { 64 | localsConvention: 'camelCase' // 默认只支持驼峰,修改为同事支持横线和驼峰 65 | }, 66 | preprocessorOptions: { 67 | less: { 68 | javascriptEnabled: true 69 | } 70 | } 71 | }, 72 | build: { 73 | emptyOutDir: false, 74 | cssCodeSplit: true, 75 | outDir: resolvePath('lib'), 76 | lib: { 77 | entry, 78 | name: 'MdEditorRT', 79 | formats: [t], 80 | fileName(format) { 81 | switch (format) { 82 | case 'es': { 83 | return 'es/[name].mjs'; 84 | } 85 | case 'cjs': { 86 | return 'cjs/[name].cjs'; 87 | } 88 | default: { 89 | return 'umd/[name].js'; 90 | } 91 | } 92 | } 93 | }, 94 | rollupOptions: { 95 | external: [ 96 | 'react', 97 | 'react-dom', 98 | 'react-dom/client', 99 | 'react/jsx-runtime', 100 | 'medium-zoom', 101 | 'lru-cache', 102 | 'codemirror', 103 | 'lucide-react', 104 | /@vavt\/.*/, 105 | /@codemirror\/.*/, 106 | /@lezer\/.*/, 107 | /markdown-it.*/ 108 | ], 109 | output: { 110 | chunkFileNames: `${t}/chunks/[name].${extnames[t]}`, 111 | assetFileNames: '[name][extname]' 112 | } 113 | } 114 | } 115 | }); 116 | }) 117 | ); 118 | })(); 119 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Modals/index.less: -------------------------------------------------------------------------------- 1 | .@{prefix} { 2 | &-clip { 3 | position: relative; 4 | display: flex; 5 | height: calc(100% - 52px); 6 | 7 | &-main, 8 | &-preview { 9 | width: 50%; 10 | height: 100%; 11 | border: 1px solid var(--md-border-color); 12 | } 13 | 14 | &-main { 15 | margin-right: 1em; 16 | 17 | .@{prefix}-clip-cropper { 18 | position: relative; 19 | width: 100%; 20 | height: 100%; 21 | 22 | .@{prefix}-clip-delete { 23 | position: absolute; 24 | top: 0; 25 | right: 0; 26 | // 幽灵空白 27 | font-size: 0; 28 | background-color: var(--md-bk-color-outstand); 29 | border-bottom-left-radius: 4px; 30 | color: var(--md-color); 31 | cursor: pointer; 32 | } 33 | } 34 | 35 | .@{prefix}-clip-upload { 36 | display: flex; 37 | align-items: center; 38 | justify-content: center; 39 | width: 100%; 40 | height: 100%; 41 | cursor: pointer; 42 | 43 | .@{prefix}-icon, 44 | .@{prefix}-iconfont { 45 | width: auto; 46 | height: 40px; 47 | font-size: 40px; 48 | } 49 | } 50 | } 51 | 52 | &-preview { 53 | &-target { 54 | width: 100%; 55 | height: 100%; 56 | overflow: hidden; 57 | } 58 | } 59 | } 60 | 61 | &-form-item { 62 | margin-bottom: 20px; 63 | text-align: center; 64 | 65 | &:last-of-type { 66 | margin-bottom: 0; 67 | } 68 | } 69 | 70 | &-label { 71 | font-size: 14px; 72 | color: var(--md-color); 73 | width: 80px; 74 | text-align: center; 75 | display: inline-block; 76 | } 77 | 78 | &-input { 79 | border-radius: 4px; 80 | padding: 4px 11px; 81 | color: var(--md-color); 82 | font-size: 14px; 83 | line-height: 1.5715; 84 | background-color: var(--md-bk-color); 85 | background-image: none; 86 | border: 1px solid var(--md-border-color); 87 | transition: all 0.2s; 88 | 89 | &:focus, 90 | &:hover { 91 | border-color: var(--md-border-hover-color); 92 | outline: 0; 93 | } 94 | 95 | &:focus { 96 | border-color: var(--md-border-active-color); 97 | } 98 | } 99 | 100 | &-btn { 101 | font-weight: 400; 102 | text-align: center; 103 | vertical-align: middle; 104 | cursor: pointer; 105 | border: 1px solid var(--md-border-color); 106 | white-space: nowrap; 107 | user-select: none; 108 | height: 32px; 109 | padding: 0 15px; 110 | font-size: 14px; 111 | border-radius: 4px; 112 | transition: all 0.2s linear; 113 | color: var(--md-color); 114 | background-color: var(--md-bk-color); 115 | border-color: var(--md-border-color); 116 | margin-left: 10px; 117 | 118 | &:first-of-type { 119 | margin-left: 0; 120 | } 121 | 122 | &:hover { 123 | color: var(--md-hover-color); 124 | background-color: var(--md-bk-color); 125 | border-color: var(--md-border-hover-color); 126 | } 127 | 128 | &-row { 129 | width: 100%; 130 | } 131 | } 132 | } 133 | 134 | @media (max-width: 688px) { 135 | .@{prefix}-modal-clip { 136 | .@{prefix}-modal { 137 | max-width: calc(100% - 20px); 138 | max-height: calc(100% - 20px); 139 | margin: 10px; 140 | left: 0 !important; 141 | } 142 | 143 | .@{prefix}-clip { 144 | flex-direction: column; 145 | 146 | &-main, 147 | &-preview { 148 | width: 100%; 149 | height: 0; 150 | flex: 1; 151 | } 152 | 153 | &-main { 154 | margin-bottom: 1em; 155 | } 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "md-editor-rt", 3 | "version": "6.2.1", 4 | "license": "MIT", 5 | "keywords": [ 6 | "react", 7 | "nextjs", 8 | "javascript", 9 | "typescript", 10 | "jsx", 11 | "tsx", 12 | "ssr", 13 | "markdown", 14 | "editor", 15 | "theme", 16 | "html" 17 | ], 18 | "description": "Markdown editor for react, developed in jsx and typescript, dark theme、beautify content by prettier、render articles directly、paste or clip the picture and upload it...", 19 | "author": { 20 | "name": "zbf", 21 | "url": "https://github.com/imzbf" 22 | }, 23 | "homepage": "https://imzbf.github.io/md-editor-rt/", 24 | "repository": { 25 | "type": "git", 26 | "url": "git+https://github.com/imzbf/md-editor-rt.git" 27 | }, 28 | "bugs": { 29 | "url": "https://github.com/imzbf/md-editor-rt/issues" 30 | }, 31 | "files": [ 32 | "lib" 33 | ], 34 | "type": "module", 35 | "types": "./lib/types/index.d.ts", 36 | "main": "./lib/cjs/index.cjs", 37 | "module": "./lib/es/index.mjs", 38 | "sideEffects": [ 39 | "**/*.css" 40 | ], 41 | "scripts": { 42 | "dev": "tsx ./scripts/dev", 43 | "build": "tsx ./scripts/build", 44 | "lint": "eslint --ext .ts,.tsx,.vue --fix packages", 45 | "lint-staged": "lint-staged", 46 | "prepare": "husky" 47 | }, 48 | "dependencies": { 49 | "@codemirror/autocomplete": "^6.18.7", 50 | "@codemirror/commands": "^6.8.1", 51 | "@codemirror/lang-markdown": "^6.3.4", 52 | "@codemirror/language": "^6.11.3", 53 | "@codemirror/language-data": "^6.5.1", 54 | "@codemirror/search": "^6.5.11", 55 | "@codemirror/state": "^6.5.2", 56 | "@codemirror/view": "^6.38.2", 57 | "@lezer/highlight": "^1.2.1", 58 | "@types/markdown-it": "^14.0.1", 59 | "@vavt/copy2clipboard": "^1.0.1", 60 | "@vavt/util": "^2.1.0", 61 | "codemirror": "^6.0.2", 62 | "lru-cache": "^11.2.1", 63 | "lucide-react": "^0.542.0", 64 | "markdown-it": "^14.0.0", 65 | "markdown-it-image-figures": "^2.1.1", 66 | "markdown-it-sub": "^2.0.0", 67 | "markdown-it-sup": "^2.0.0", 68 | "medium-zoom": "^1.1.0", 69 | "xss": "^1.0.15" 70 | }, 71 | "devDependencies": { 72 | "@eslint/js": "^9.35.0", 73 | "@types/katex": "^0.16.7", 74 | "@types/multiparty": "^4.2.1", 75 | "@types/node": "^24.3.1", 76 | "@types/react": "^19.1.12", 77 | "@types/react-dom": "^19.1.9", 78 | "@typescript-eslint/eslint-plugin": "^8.43.0", 79 | "@typescript-eslint/parser": "^8.43.0", 80 | "@vavt/markdown-theme": "^4.5.3", 81 | "@vavt/vite-plugin-import-markdown": "^1.0.1", 82 | "@vitejs/plugin-react": "^5.0.2", 83 | "axios": "^1.11.0", 84 | "cropperjs": "^1.6.2", 85 | "echarts": "^6.0.0", 86 | "eslint": "^9.35.0", 87 | "eslint-config-prettier": "^10.1.8", 88 | "eslint-import-resolver-typescript": "^4.4.4", 89 | "eslint-plugin-import": "^2.32.0", 90 | "eslint-plugin-prettier": "^5.5.4", 91 | "eslint-plugin-react-hooks": "^5.0.0", 92 | "eslint-plugin-react-refresh": "^0.4.13", 93 | "globals": "^16.3.0", 94 | "highlight.js": "^11.10.0", 95 | "husky": "^9.1.7", 96 | "katex": "^0.16.22", 97 | "less": "^4.4.1", 98 | "lint-staged": "^16.1.6", 99 | "mermaid": "^11.11.0", 100 | "multiparty": "^4.2.3", 101 | "prettier": "^3.6.2", 102 | "react": "^19.1.1", 103 | "react-dom": "^19.1.1", 104 | "screenfull": "^6.0.2", 105 | "tsc-alias": "^1.8.10", 106 | "tsx": "^4.20.5", 107 | "typescript": "^5.9.2", 108 | "typescript-eslint": "^8.43.0", 109 | "vite": "^7.1.5" 110 | }, 111 | "peerDependencies": { 112 | "react": ">=18.0.0", 113 | "react-dom": ">=18.0.0" 114 | }, 115 | "lint-staged": { 116 | "*.{ts,tsx}": [ 117 | "eslint --fix", 118 | "prettier --write" 119 | ], 120 | "*.{json,md}": [ 121 | "prettier --write" 122 | ] 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Content/codemirror/floatingToolbar.tsx: -------------------------------------------------------------------------------- 1 | import { StateEffect, StateField } from '@codemirror/state'; 2 | import { EditorView, showTooltip, Tooltip } from '@codemirror/view'; 3 | import { createContext, useContext, useSyncExternalStore } from 'react'; 4 | import { createRoot } from 'react-dom/client'; 5 | import { prefix } from '~/config'; 6 | import { defaultContextValue } from '~/context'; 7 | import Toolbar from '~/layouts/FloatingToolbar'; 8 | import { ContextType } from '~/type'; 9 | 10 | export interface FloatingToolbarContextValue { 11 | getValue: () => T; 12 | subscribe: (callback: () => void) => () => void; 13 | } 14 | 15 | export const FloatingToolbarContext = createContext< 16 | FloatingToolbarContextValue 17 | >({ 18 | getValue: () => defaultContextValue, 19 | subscribe: () => () => {} 20 | }); 21 | 22 | export const useFloatingToolbarValue = () => { 23 | const ctx = useContext(FloatingToolbarContext); 24 | 25 | return useSyncExternalStore(ctx.subscribe, ctx.getValue); 26 | }; 27 | 28 | const tooltipEffect = StateEffect.define(); 29 | 30 | const tooltipField = StateField.define({ 31 | create() { 32 | return null; 33 | }, 34 | update(value, tr) { 35 | for (const e of tr.effects) if (e.is(tooltipEffect)) value = e.value; 36 | return value; 37 | }, 38 | provide: (f) => showTooltip.from(f) 39 | }); 40 | 41 | export const createFloatingToolbar = (options: { 42 | contextValue: FloatingToolbarContextValue; 43 | }) => { 44 | type TooltipState = { kind: 'selection' | 'emptyLine'; pos: number }; 45 | 46 | let lastTooltip: TooltipState | null = null; 47 | 48 | const showTooltip = (view: EditorView, nextState: TooltipState) => { 49 | if ( 50 | lastTooltip && 51 | lastTooltip.kind === nextState.kind && 52 | lastTooltip.pos === nextState.pos 53 | ) { 54 | return; 55 | } 56 | 57 | lastTooltip = nextState; 58 | 59 | view.dispatch({ 60 | effects: tooltipEffect.of({ 61 | pos: nextState.pos, 62 | above: true, 63 | arrow: true, 64 | create: () => { 65 | const dom = document.createElement('div'); 66 | const tooltipClass = `${prefix}-floating-toolbar-container`; 67 | 68 | dom.classList.add(tooltipClass); 69 | dom.dataset.state = 'hidden'; 70 | 71 | requestAnimationFrame(() => { 72 | dom.dataset.state = 'visible'; 73 | }); 74 | 75 | // 这里需要创建一个 react 根节点 76 | // 如果直接使用dom,每次react更新都会重置dom中codemirror添加的节点,比如箭头 77 | const appNode = document.createElement('div'); 78 | dom.appendChild(appNode); 79 | 80 | const root = createRoot(appNode); 81 | 82 | root.render( 83 | 84 | 85 | 86 | ); 87 | 88 | return { dom, destroy: () => root.unmount() }; 89 | } 90 | }) 91 | }); 92 | }; 93 | 94 | const hideTooltip = (view: EditorView) => { 95 | if (!lastTooltip) return; 96 | 97 | lastTooltip = null; 98 | view.dispatch({ effects: tooltipEffect.of(null) }); 99 | }; 100 | 101 | const selectionAndEmptyLineTooltip = EditorView.updateListener.of((update) => { 102 | if (update.selectionSet || update.docChanged) { 103 | const state = update.state; 104 | const sel = state.selection.main; 105 | 106 | if (!sel.empty) { 107 | // 选中文字 → 显示 108 | showTooltip(update.view, { kind: 'selection', pos: sel.anchor }); 109 | } else { 110 | // 光标位置 → 判断是不是空白行 111 | const pos = sel.head; 112 | const line = state.doc.lineAt(pos); 113 | if (/^\s*$/.test(line.text)) { 114 | showTooltip(update.view, { kind: 'emptyLine', pos }); 115 | } else { 116 | hideTooltip(update.view); 117 | } 118 | } 119 | } 120 | }); 121 | 122 | return [tooltipField, selectionAndEmptyLineTooltip]; 123 | }; 124 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Toolbar/tools/Title.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useCallback, useContext, useMemo, useState } from 'react'; 2 | import DropDown from '~/components/Dropdown'; 3 | import Icon from '~/components/Icon'; 4 | import { prefix } from '~/config'; 5 | import { EditorContext } from '~/context'; 6 | import { REPLACE } from '~/static/event-name'; 7 | import { classnames } from '~/utils'; 8 | import { ToolDirective } from '~/utils/content-help'; 9 | import bus from '~/utils/event-bus'; 10 | 11 | const ToolbarTitle = () => { 12 | const { 13 | editorId, 14 | usedLanguageText: ult, 15 | showToolbarName, 16 | disabled 17 | } = useContext(EditorContext); 18 | const wrapperId = `${editorId}-toolbar-wrapper`; 19 | const [visible, setVisible] = useState(false); 20 | 21 | const emitHandler = useCallback( 22 | (direct: ToolDirective) => { 23 | if (disabled) return; 24 | bus.emit(editorId, REPLACE, direct); 25 | }, 26 | [disabled, editorId] 27 | ); 28 | 29 | const overlay = useMemo(() => { 30 | return ( 31 |
    { 34 | setVisible(false); 35 | }} 36 | role="menu" 37 | > 38 |
  • { 41 | emitHandler('h1'); 42 | }} 43 | role="menuitem" 44 | tabIndex={0} 45 | > 46 | {ult.titleItem?.h1} 47 |
  • 48 |
  • { 51 | emitHandler('h2'); 52 | }} 53 | role="menuitem" 54 | tabIndex={0} 55 | > 56 | {ult.titleItem?.h2} 57 |
  • 58 |
  • { 61 | emitHandler('h3'); 62 | }} 63 | role="menuitem" 64 | tabIndex={0} 65 | > 66 | {ult.titleItem?.h3} 67 |
  • 68 |
  • { 71 | emitHandler('h4'); 72 | }} 73 | role="menuitem" 74 | tabIndex={0} 75 | > 76 | {ult.titleItem?.h4} 77 |
  • 78 |
  • { 81 | emitHandler('h5'); 82 | }} 83 | role="menuitem" 84 | tabIndex={0} 85 | > 86 | {ult.titleItem?.h5} 87 |
  • 88 |
  • { 91 | emitHandler('h6'); 92 | }} 93 | role="menuitem" 94 | tabIndex={0} 95 | > 96 | {ult.titleItem?.h6} 97 |
  • 98 |
99 | ); 100 | }, [ 101 | emitHandler, 102 | ult.titleItem?.h1, 103 | ult.titleItem?.h2, 104 | ult.titleItem?.h3, 105 | ult.titleItem?.h4, 106 | ult.titleItem?.h5, 107 | ult.titleItem?.h6 108 | ]); 109 | 110 | const child = useMemo(() => { 111 | return ( 112 | 126 | ); 127 | }, [disabled, showToolbarName, ult.toolbarTips?.title]); 128 | 129 | return ( 130 | 137 | {child} 138 | 139 | ); 140 | }; 141 | 142 | export default memo(ToolbarTitle); 143 | -------------------------------------------------------------------------------- /packages/MdEditor/styles/codeMirror.less: -------------------------------------------------------------------------------- 1 | .@{prefix} { 2 | .cm-editor { 3 | font-size: 14px; 4 | height: 100%; 5 | 6 | &.cm-focused { 7 | outline: none; 8 | } 9 | 10 | .cm-tooltip.cm-tooltip-autocomplete { 11 | border-radius: 3px; 12 | 13 | & > ul { 14 | border-radius: 3px; 15 | min-width: fit-content; 16 | max-width: fit-content; 17 | 18 | li { 19 | background-color: var(--md-bk-color); 20 | color: var(--md-color); 21 | padding: 4px 10px; 22 | line-height: 16px; 23 | 24 | // 自动提示语的图标 25 | .cm-completionIcon { 26 | width: auto; 27 | } 28 | } 29 | 30 | li[aria-selected] { 31 | background-color: var(--md-bk-hover-color); 32 | } 33 | } 34 | 35 | .cm-completionInfo { 36 | margin-top: -2px; 37 | margin-left: 3px; 38 | padding: 4px 9px; 39 | border-radius: 3px; 40 | overflow: hidden; 41 | // 因为它是跟随着选中项,所以默认选择背景 42 | background-color: var(--md-bk-hover-color); 43 | color: var(--md-color); 44 | } 45 | } 46 | } 47 | 48 | // &-input-wrapper:has(+ *) { 49 | // .cm-scroller { 50 | // overflow-y: scroll; 51 | // } 52 | // } 53 | 54 | .cm-scroller { 55 | // Firefox 56 | scrollbar-width: none; 57 | 58 | &::-webkit-scrollbar { 59 | // Chrome/Safari 60 | display: none; 61 | } 62 | 63 | // 当没有代码行号的时候,给编辑区一个10的外间距 64 | .cm-content[contenteditable='true'] { 65 | margin: 10px; 66 | min-height: calc(100% - 20px); 67 | } 68 | 69 | // 当有代码行号时,把外间距设置为默认值 70 | .cm-gutters + .cm-content[contenteditable='true'] { 71 | margin: 0; 72 | min-height: 100%; 73 | } 74 | 75 | .cm-line { 76 | line-height: inherit; 77 | } 78 | } 79 | 80 | .ͼ1 .cm-scroller { 81 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; 82 | line-height: 20px; 83 | } 84 | 85 | .cm-search { 86 | // 输入框 87 | .cm-textfield { 88 | border-radius: 4px; 89 | padding: 4px 11px; 90 | color: var(--md-color); 91 | font-size: 10px; 92 | background-image: none; 93 | border: 1px solid var(--md-border-color); 94 | transition: all 0.2s; 95 | 96 | &:focus, 97 | &:hover { 98 | border-color: var(--md-border-hover-color); 99 | outline: 0; 100 | } 101 | 102 | &:focus { 103 | border-color: var(--md-border-active-color); 104 | } 105 | } 106 | 107 | .cm-button { 108 | font-weight: 400; 109 | text-align: center; 110 | vertical-align: middle; 111 | cursor: pointer; 112 | border: 1px solid var(--md-border-color); 113 | white-space: nowrap; 114 | user-select: none; 115 | height: 20px; 116 | padding: 0 15px; 117 | font-size: 10px; 118 | border-radius: 4px; 119 | transition: all 0.2s linear; 120 | color: var(--md-color); 121 | background-color: inherit; 122 | background-image: none; 123 | border-color: var(--md-border-color); 124 | 125 | &:first-of-type { 126 | margin-left: 0; 127 | } 128 | 129 | &:hover { 130 | color: var(--md-hover-color); 131 | background-color: inherit; 132 | border-color: var(--md-border-hover-color); 133 | } 134 | } 135 | // 属性选择器 136 | input[type='checkbox'] { 137 | vertical-align: sub; 138 | 139 | &::after { 140 | display: block; 141 | content: ''; 142 | font-weight: bold; 143 | cursor: pointer; 144 | width: 12px; 145 | height: 12px; 146 | border: 1px solid var(--md-border-color); 147 | background-color: var(--md-bk-color-outstand); 148 | border-radius: 2px; 149 | line-height: 1; 150 | text-align: center; 151 | } 152 | 153 | &:checked::after { 154 | content: '✓'; 155 | color: var(--md-color); 156 | } 157 | } 158 | 159 | // 关闭按钮 160 | button[name='close'] { 161 | color: inherit; 162 | cursor: pointer; 163 | right: 6px; 164 | } 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Toolbar/TableShape.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useState, useEffect, useMemo, memo } from 'react'; 2 | import { prefix } from '~/config'; 3 | import { TableShapeType } from '~/type'; 4 | 5 | export interface HoverData { 6 | x: number; 7 | y: number; 8 | } 9 | 10 | interface TableShapeProps { 11 | tableShape: TableShapeType; 12 | onSelected: (data: HoverData) => void; 13 | } 14 | 15 | const TableShape = (props: TableShapeProps) => { 16 | const [hoverPosition, setHoverPosition] = useState({ 17 | x: -1, 18 | y: -1 19 | }); 20 | 21 | // 解决用户直接赋值的时导致的重复渲染,例如 tableShape={[1,2,3,4]} 22 | const tableShapeStr = useMemo(() => { 23 | return JSON.stringify(props.tableShape); 24 | }, [props.tableShape]); 25 | 26 | const initShape = useCallback(() => { 27 | const shape = [...JSON.parse(tableShapeStr)]; 28 | 29 | if (!shape[2] || shape[2] < shape[0]) { 30 | shape[2] = shape[0]; 31 | } 32 | 33 | if (!shape[3] || shape[3] < shape[3]) { 34 | shape[3] = shape[1]; 35 | } 36 | 37 | return shape; 38 | }, [tableShapeStr]); 39 | 40 | const [tableShape, setTableShape] = useState(initShape); 41 | 42 | useEffect(() => { 43 | setTableShape(initShape); 44 | setHoverPosition({ 45 | x: -1, 46 | y: -1 47 | }); 48 | }, [initShape]); 49 | 50 | return ( 51 |
{ 54 | setTableShape(initShape); 55 | setHoverPosition({ 56 | x: -1, 57 | y: -1 58 | }); 59 | }} 60 | > 61 | {new Array(tableShape[1]).fill('').map((_, rowIndex) => ( 62 |
63 | {new Array(tableShape[0]).fill('').map((_, colIndex) => ( 64 |
{ 68 | setHoverPosition({ 69 | x: rowIndex, 70 | y: colIndex 71 | }); 72 | 73 | if (colIndex + 1 === tableShape[0] && colIndex + 1 < tableShape[2]) { 74 | setTableShape((ts) => { 75 | const shapeCopy = [...ts]; 76 | shapeCopy[0] = ts[0] + 1; 77 | return shapeCopy; 78 | }); 79 | } else if ( 80 | colIndex + 2 < tableShape[0] && 81 | tableShape[0] > props.tableShape[0] 82 | ) { 83 | setTableShape((ts) => { 84 | const shapeCopy = [...ts]; 85 | shapeCopy[0] = ts[0] - 1; 86 | return shapeCopy; 87 | }); 88 | } 89 | 90 | if (rowIndex + 1 === tableShape[1] && rowIndex + 1 < tableShape[3]) { 91 | setTableShape((ts) => { 92 | const shapeCopy = [...ts]; 93 | shapeCopy[1] = ts[1] + 1; 94 | return shapeCopy; 95 | }); 96 | } else if ( 97 | rowIndex + 2 < tableShape[1] && 98 | tableShape[1] > props.tableShape[1] 99 | ) { 100 | setTableShape((ts) => { 101 | const shapeCopy = [...ts]; 102 | shapeCopy[1] = ts[1] - 1; 103 | return shapeCopy; 104 | }); 105 | } 106 | }} 107 | onClick={() => { 108 | props.onSelected(hoverPosition); 109 | }} 110 | > 111 |
!!c) 119 | .join(' ')} 120 | >
121 |
122 | ))} 123 |
124 | ))} 125 |
126 | ); 127 | }; 128 | 129 | export default memo(TableShape); 130 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Content/hooks/usePasteUpload.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useCallback, useContext } from 'react'; 2 | import { EditorContext } from '~/context'; 3 | import { ERROR_CATCHER, REPLACE, UPLOAD_IMAGE } from '~/static/event-name'; 4 | import bus from '~/utils/event-bus'; 5 | 6 | import CodeMirrorUt from '../codemirror'; 7 | import { ContentProps } from '../props'; 8 | 9 | /** 10 | * 处理粘贴板 11 | */ 12 | const usePasteUpload = ( 13 | props: ContentProps, 14 | codeMirrorUt: RefObject 15 | ) => { 16 | const { editorId } = useContext(EditorContext); 17 | 18 | const imgInsert = useCallback( 19 | (tv: string | Promise) => { 20 | if (tv instanceof Promise) { 21 | tv.then((targetValue) => { 22 | bus.emit(editorId, REPLACE, 'universal', { 23 | generate() { 24 | return { 25 | targetValue 26 | }; 27 | } 28 | }); 29 | }).catch((err) => { 30 | console.error(err); 31 | }); 32 | } else { 33 | bus.emit(editorId, REPLACE, 'universal', { 34 | generate() { 35 | return { 36 | targetValue: tv 37 | }; 38 | } 39 | }); 40 | } 41 | }, 42 | [editorId] 43 | ); 44 | 45 | // 粘贴板上传 46 | const pasteHandler = useCallback( 47 | (e: ClipboardEvent) => { 48 | if (!e.clipboardData) { 49 | return; 50 | } 51 | 52 | // 处理文件 53 | if (e.clipboardData.files.length > 0) { 54 | const { files } = e.clipboardData; 55 | 56 | bus.emit( 57 | editorId, 58 | UPLOAD_IMAGE, 59 | Array.from(files).filter((file) => { 60 | return /image\/.*/.test(file.type); 61 | }) 62 | ); 63 | 64 | e.preventDefault(); 65 | return; 66 | } 67 | const targetValue = e.clipboardData.getData('text/plain'); 68 | 69 | const to = codeMirrorUt.current?.view.state.selection.main.to || 0; 70 | const from = codeMirrorUt.current?.view.state.doc.lineAt(to).from || 0; 71 | // 当前光标到当前行开头的字符串 72 | const lineStart = codeMirrorUt.current?.view.state.doc.sliceString(from, to) || ''; 73 | 74 | // 图片语法在当前行开头 75 | const templateStart = /!\[.*\]\(\s*$/.test(lineStart); 76 | // 图片语法在粘贴的内容中 77 | const templateIn = /!\[.*\]\((.*)\s?.*\)/.test(targetValue); 78 | 79 | if (templateStart) { 80 | const tv = props.transformImgUrl(targetValue); 81 | imgInsert(tv); 82 | 83 | e.preventDefault(); 84 | return; 85 | } else if (templateIn) { 86 | const matchArr = targetValue.match( 87 | /(?<=!\[.*\]\()([^)\s]+)(?=\s?["']?.*["']?\))/g 88 | ); 89 | 90 | if (matchArr) { 91 | Promise.all( 92 | matchArr.map((img: string) => { 93 | return props.transformImgUrl(img); 94 | }) 95 | ) 96 | .then((newUrls: string[]) => { 97 | imgInsert( 98 | newUrls.reduce((prev, curr, index) => { 99 | return prev.replace(matchArr[index], curr); 100 | }, targetValue) 101 | ); 102 | }) 103 | .catch((err) => { 104 | console.error(err); 105 | }); 106 | } else { 107 | imgInsert(targetValue); 108 | } 109 | 110 | e.preventDefault(); 111 | return; 112 | } 113 | 114 | // 识别vscode代码 115 | if (props.autoDetectCode && e.clipboardData.types.includes('vscode-editor-data')) { 116 | const vscCoodInfo = JSON.parse(e.clipboardData.getData('vscode-editor-data')); 117 | 118 | bus.emit(editorId, REPLACE, 'code', { 119 | mode: vscCoodInfo.mode, 120 | text: e.clipboardData.getData('text/plain') 121 | }); 122 | 123 | e.preventDefault(); 124 | return; 125 | } 126 | 127 | if ( 128 | props.maxLength && 129 | targetValue.length + props.modelValue.length > props.maxLength 130 | ) { 131 | bus.emit(editorId, ERROR_CATCHER, { 132 | name: 'overlength', 133 | message: 'The input text is too long', 134 | data: targetValue 135 | }); 136 | } 137 | }, 138 | [codeMirrorUt, editorId, imgInsert, props] 139 | ); 140 | 141 | return pasteHandler; 142 | }; 143 | export default usePasteUpload; 144 | -------------------------------------------------------------------------------- /packages/MdEditor/layouts/Content/codemirror/autocompletion.ts: -------------------------------------------------------------------------------- 1 | import { 2 | autocompletion, 3 | CompletionContext, 4 | CompletionResult, 5 | Completion, 6 | CompletionSource 7 | } from '@codemirror/autocomplete'; 8 | import { EditorSelection } from '@codemirror/state'; 9 | 10 | const getPairApply = ( 11 | flag: string, 12 | type: string, 13 | title: string, 14 | suffix: string, 15 | selectType: 'type' | 'title' 16 | ): Completion['apply'] => { 17 | return (view, _completion, from, to) => { 18 | // 替换全部而非补充后面的部分 19 | const insert = `${flag}${type}${title}${suffix}`; 20 | 21 | // 开始+标记和类型+是否选中title 22 | const newTo = 23 | from + _completion.label.length + (selectType === 'title' ? title.length : 0); 24 | 25 | view.dispatch({ 26 | changes: { from, to, insert }, 27 | selection: EditorSelection.create( 28 | [ 29 | EditorSelection.range( 30 | from + _completion.label.length + (selectType === 'title' ? 1 : -type.length), 31 | newTo 32 | ), 33 | EditorSelection.cursor(newTo) 34 | ], 35 | 1 36 | ) 37 | }); 38 | 39 | view.focus(); 40 | }; 41 | }; 42 | 43 | const getApply = (_label: string): Completion['apply'] => { 44 | return (view, _completion, from, to) => { 45 | const label = _label.slice(to - from); 46 | view.dispatch(view.state.replaceSelection(`${label} `)); 47 | }; 48 | }; 49 | 50 | export const createAutocompletion = ( 51 | completions: Array | undefined 52 | ) => { 53 | const defaultCompletion = (context: CompletionContext): CompletionResult | null => { 54 | const word = context.matchBefore( 55 | /^#+|^-\s*\[*\s*\]*|`+|\[|!\[*|^\|\s?\|?|\$\$?|!+\s*\w*/ 56 | ); 57 | 58 | if (word === null || (word.from == word.to && context.explicit)) { 59 | return null; 60 | } 61 | 62 | return { 63 | from: word.from, 64 | options: [ 65 | // 标题 66 | ...['h2', 'h3', 'h4', 'h5', 'h6'].map((key, index) => { 67 | const label = new Array(index + 2).fill('#').join(''); 68 | return { 69 | label, 70 | type: 'text', 71 | apply: getApply(label) 72 | }; 73 | }), 74 | // 任务列表 75 | ...['unchecked', 'checked'].map((key) => { 76 | const label = key === 'checked' ? '- [x]' : '- [ ]'; 77 | return { 78 | label, 79 | type: 'text', 80 | apply: getApply(label) 81 | }; 82 | }), 83 | // 代码 84 | ...[ 85 | ['`', ''], 86 | ['```', 'language'], 87 | ['```mermaid\n', ''], 88 | ['```echarts\n', ''] 89 | ].map((c) => { 90 | return { 91 | label: `${c[0]}${c[1]}`, 92 | type: 'text', 93 | apply: getPairApply(c[0], c[1], '', c[0] === '`' ? '`' : '\n```', 'type') 94 | }; 95 | }), 96 | // 链接 97 | { 98 | label: '[]()', 99 | type: 'text' 100 | }, 101 | { 102 | label: '![]()', 103 | type: 'text' 104 | }, 105 | // 表格 106 | { 107 | label: '| |', 108 | type: 'text', 109 | detail: 'table', 110 | apply: 111 | '| col | col | col |\n| - | - | - |\n| content | content | content |\n| content | content | content |' 112 | }, 113 | // 公式 114 | { 115 | label: '$', 116 | type: 'text', 117 | apply: getPairApply('$', '', '', '$', 'type') 118 | }, 119 | { 120 | label: '$$', 121 | type: 'text', 122 | apply: getPairApply('$$', '', '\n', '\n$$', 'title') 123 | }, 124 | // 那啥? 125 | ...[ 126 | 'note', 127 | 'abstract', 128 | 'info', 129 | 'tip', 130 | 'success', 131 | 'question', 132 | 'warning', 133 | 'failure', 134 | 'danger', 135 | 'bug', 136 | 'example', 137 | 'quote', 138 | 'hint', 139 | 'caution', 140 | 'error', 141 | 'attention' 142 | ].map((key) => { 143 | const label = `!!! ${key}`; 144 | return { 145 | label, 146 | type: 'text', 147 | apply: getPairApply('!!!', ` ${key}`, ' Title', '\n\n!!!', 'title') 148 | }; 149 | }) 150 | ] 151 | }; 152 | }; 153 | 154 | return autocompletion({ 155 | override: completions ? [defaultCompletion, ...completions] : [defaultCompletion] 156 | }); 157 | }; 158 | --------------------------------------------------------------------------------