├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .npmignore ├── .prettierrc.js ├── .storybook ├── main.js ├── preview-head.html └── preview.js ├── README.md ├── babel.config.js ├── package.json ├── pnpm-lock.yaml ├── src ├── components │ ├── bubble-menu.tsx │ ├── button.tsx │ ├── color-picker │ │ ├── index.tsx │ │ └── style.tsx │ ├── divider.tsx │ ├── index.tsx │ ├── loading.tsx │ ├── react-bubble-menu │ │ ├── bubble-menu-pluin │ │ │ ├── bubble-menu-plugin.ts │ │ │ ├── bubble-menu.ts │ │ │ └── index.ts │ │ └── index.tsx │ ├── react-tooltip.tsx │ ├── resizable.tsx │ ├── select.tsx │ ├── tooltip.tsx │ └── upload.tsx ├── constants.ts ├── editor │ ├── collaboration.tsx │ ├── index.tsx │ ├── kit.ts │ ├── provider.ts │ ├── render.tsx │ ├── theme.ts │ └── utilities.ts ├── extensions │ ├── blockquote │ │ ├── blockquote.ts │ │ ├── index.ts │ │ └── menu.tsx │ ├── bold │ │ ├── bold.ts │ │ ├── index.ts │ │ └── menu.tsx │ ├── bullet-list │ │ ├── bullet-list.ts │ │ └── index.ts │ ├── code-block │ │ ├── code-block-view.tsx │ │ ├── code-block.ts │ │ ├── index.ts │ │ ├── lowlight-plugin.ts │ │ └── menu.tsx │ ├── code │ │ ├── code.ts │ │ ├── index.ts │ │ └── menu.tsx │ ├── collaboration-cursor │ │ ├── collaboration-cursor.ts │ │ ├── cursor-plugin.ts │ │ └── index.ts │ ├── collaboration │ │ ├── collaboration.ts │ │ ├── index.ts │ │ ├── is-change-origin.ts │ │ └── y-prosemirror │ │ │ ├── index.js │ │ │ ├── lib.js │ │ │ └── plugins │ │ │ ├── cursor-plugin.js │ │ │ ├── keys.js │ │ │ ├── sync-plugin.js │ │ │ └── undo-plugin.js │ ├── columns │ │ ├── column.ts │ │ ├── columns.ts │ │ ├── index.ts │ │ ├── menu │ │ │ ├── bubble.tsx │ │ │ ├── index.tsx │ │ │ └── static.tsx │ │ └── utilities.ts │ ├── dragable │ │ ├── dragable.ts │ │ ├── index.ts │ │ └── utilities.ts │ ├── dropcursor │ │ ├── dropcursor.ts │ │ ├── index.ts │ │ └── prosemirror-dropcursor.ts │ ├── excalidraw │ │ ├── excalidraw-view.tsx │ │ ├── excalidraw.ts │ │ ├── index.ts │ │ └── menu │ │ │ ├── bubble.tsx │ │ │ ├── edit.tsx │ │ │ ├── index.tsx │ │ │ ├── modal.tsx │ │ │ └── static.tsx │ ├── flow │ │ ├── flow-view.tsx │ │ ├── flow.ts │ │ ├── index.ts │ │ └── menu │ │ │ ├── bubble.tsx │ │ │ ├── constants.ts │ │ │ ├── edit.tsx │ │ │ ├── index.tsx │ │ │ ├── modal.tsx │ │ │ └── static.tsx │ ├── focus │ │ ├── focus.ts │ │ └── index.ts │ ├── gapcursor │ │ ├── gapcursor.ts │ │ └── index.ts │ ├── hard-break │ │ ├── hard-break.ts │ │ └── index.ts │ ├── heading │ │ ├── constants.ts │ │ ├── heading.ts │ │ ├── index.ts │ │ ├── menu.tsx │ │ ├── slug.ts │ │ └── utilities.ts │ ├── horizontal-rule │ │ ├── horizontal-rule.ts │ │ ├── index.ts │ │ └── menu.tsx │ ├── iframe │ │ ├── iframe-view.tsx │ │ ├── iframe.ts │ │ ├── index.ts │ │ └── menu │ │ │ ├── bubble.tsx │ │ │ ├── edit.tsx │ │ │ ├── index.tsx │ │ │ └── static.tsx │ ├── image │ │ ├── image-view.tsx │ │ ├── image.ts │ │ ├── index.ts │ │ └── menu │ │ │ ├── bubble-menu.tsx │ │ │ ├── index.tsx │ │ │ └── static-menu.tsx │ ├── index.ts │ ├── italic │ │ ├── index.ts │ │ ├── italic.ts │ │ └── menu.tsx │ ├── link │ │ ├── index.ts │ │ ├── link.ts │ │ └── menu │ │ │ ├── bubble.tsx │ │ │ ├── edit.tsx │ │ │ ├── index.tsx │ │ │ └── static.tsx │ ├── list-item │ │ ├── index.ts │ │ └── list-item.ts │ ├── loading │ │ ├── index.ts │ │ ├── loading-view.tsx │ │ ├── loading.ts │ │ └── utilities.ts │ ├── mention │ │ ├── index.ts │ │ ├── mention-menu-view.tsx │ │ └── mention.tsx │ ├── mind │ │ ├── index.ts │ │ ├── menu │ │ │ ├── bubble.tsx │ │ │ ├── constant.ts │ │ │ ├── edit.tsx │ │ │ ├── index.tsx │ │ │ ├── kityminder │ │ │ │ ├── README.md │ │ │ │ ├── constant.ts │ │ │ │ ├── hotbox.js │ │ │ │ ├── index.ts │ │ │ │ ├── kityminder.core.js │ │ │ │ └── kityminder.editor.js │ │ │ ├── modal.tsx │ │ │ ├── static.tsx │ │ │ ├── style.tsx │ │ │ └── toolbar │ │ │ │ ├── bgcolor.tsx │ │ │ │ ├── font-color.tsx │ │ │ │ ├── help.tsx │ │ │ │ ├── image.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── link.tsx │ │ │ │ ├── priority.tsx │ │ │ │ ├── progress.tsx │ │ │ │ ├── styled.tsx │ │ │ │ ├── template.tsx │ │ │ │ └── theme.tsx │ │ ├── mind-view.tsx │ │ └── mind.ts │ ├── ordered-list │ │ ├── index.ts │ │ └── ordered-list.ts │ ├── paragraph │ │ ├── index.ts │ │ └── paragraph.ts │ ├── perf │ │ ├── analytics.ts │ │ ├── index.ts │ │ ├── perf.ts │ │ └── utilities.ts │ ├── placeholder │ │ ├── index.ts │ │ └── placeholder.ts │ ├── slash │ │ ├── index.ts │ │ ├── slash-menu-view.tsx │ │ └── slash.tsx │ ├── status │ │ ├── index.ts │ │ ├── menu.tsx │ │ ├── status-view.tsx │ │ └── status.ts │ ├── strike │ │ ├── index.ts │ │ ├── menu.tsx │ │ └── strike.ts │ ├── subscript │ │ ├── index.ts │ │ ├── menu.tsx │ │ └── subscript.ts │ ├── superscript │ │ ├── index.ts │ │ ├── menu.tsx │ │ └── superscript.ts │ ├── table │ │ ├── cell-menu-plugin │ │ │ └── index.tsx │ │ ├── index.ts │ │ ├── menu │ │ │ ├── bubble.tsx │ │ │ ├── index.tsx │ │ │ └── static.tsx │ │ ├── table-cell │ │ │ └── index.tsx │ │ ├── table-header │ │ │ └── index.tsx │ │ ├── table-kit.ts │ │ ├── table-row │ │ │ └── index.ts │ │ ├── table │ │ │ ├── index.ts │ │ │ └── table-view.ts │ │ └── utilities │ │ │ └── index.ts │ ├── text-align │ │ ├── index.ts │ │ ├── menu.tsx │ │ └── text-align.ts │ ├── text │ │ ├── index.ts │ │ └── text.ts │ ├── trailing-node │ │ ├── index.ts │ │ └── trailing-node.ts │ ├── underline │ │ ├── index.ts │ │ ├── menu.tsx │ │ └── underline.ts │ └── unique-id │ │ ├── index.ts │ │ ├── unique-id.ts │ │ └── utilities │ │ ├── array-difference.ts │ │ ├── combine-transaction-steps.ts │ │ ├── find-duplicates.ts │ │ ├── get-changed-ranges.ts │ │ └── remove-duplicates.ts ├── hooks │ ├── index.tsx │ ├── use-active.tsx │ └── use-attributes.tsx ├── i18n │ ├── en-us │ │ └── index.ts │ ├── index.ts │ ├── ko-kr │ │ └── index.ts │ └── zh-cn │ │ └── index.ts ├── icons │ ├── icon.tsx │ └── index.tsx ├── index.tsx ├── prosemirror │ ├── index.ts │ └── prosemirror-utils │ │ ├── README.md │ │ ├── helpers.js │ │ ├── index.d.ts │ │ ├── index.js │ │ ├── node.js │ │ ├── selection.js │ │ └── transforms.js ├── stories │ ├── collaboration.stories.tsx │ ├── content.ts │ └── editor.stories.tsx ├── styles │ ├── editor.ts │ └── theme.ts └── utilities │ ├── clamp.ts │ ├── copy │ ├── copy-to-clipboard.js │ └── index.ts │ ├── create-node.ts │ ├── file.ts │ ├── index.ts │ ├── mark.ts │ ├── node.ts │ ├── pos.ts │ ├── svg-to-datauri.ts │ └── throttle.ts ├── tsconfig.json ├── types └── src │ ├── components │ ├── bubble-menu.d.ts │ ├── button.d.ts │ ├── color-picker │ │ ├── index.d.ts │ │ └── style.d.ts │ ├── divider.d.ts │ ├── index.d.ts │ ├── loading.d.ts │ ├── react-bubble-menu │ │ ├── bubble-menu-pluin │ │ │ ├── bubble-menu-plugin.d.ts │ │ │ ├── bubble-menu.d.ts │ │ │ └── index.d.ts │ │ └── index.d.ts │ ├── react-tooltip.d.ts │ ├── resizable.d.ts │ ├── select.d.ts │ ├── tooltip.d.ts │ └── upload.d.ts │ ├── constants.d.ts │ ├── editor │ ├── collaboration.d.ts │ ├── index.d.ts │ ├── kit.d.ts │ ├── provider.d.ts │ ├── render.d.ts │ ├── theme.d.ts │ └── utilities.d.ts │ ├── extensions │ ├── blockquote │ │ ├── blockquote.d.ts │ │ ├── index.d.ts │ │ └── menu.d.ts │ ├── bold │ │ ├── bold.d.ts │ │ ├── index.d.ts │ │ └── menu.d.ts │ ├── bullet-list │ │ ├── bullet-list.d.ts │ │ └── index.d.ts │ ├── code-block │ │ ├── code-block-view.d.ts │ │ ├── code-block.d.ts │ │ ├── index.d.ts │ │ ├── lowlight-plugin.d.ts │ │ └── menu.d.ts │ ├── code │ │ ├── code.d.ts │ │ ├── index.d.ts │ │ └── menu.d.ts │ ├── collaboration-cursor │ │ ├── collaboration-cursor.d.ts │ │ └── index.d.ts │ ├── collaboration │ │ ├── collaboration.d.ts │ │ ├── index.d.ts │ │ └── is-change-origin.d.ts │ ├── columns │ │ ├── column.d.ts │ │ ├── columns.d.ts │ │ ├── index.d.ts │ │ ├── menu │ │ │ ├── bubble.d.ts │ │ │ ├── index.d.ts │ │ │ └── static.d.ts │ │ └── utilities.d.ts │ ├── dragable │ │ ├── dragable.d.ts │ │ ├── index.d.ts │ │ └── utilities.d.ts │ ├── dropcursor │ │ ├── dropcursor.d.ts │ │ ├── index.d.ts │ │ └── prosemirror-dropcursor.d.ts │ ├── excalidraw │ │ ├── excalidraw-view.d.ts │ │ ├── excalidraw.d.ts │ │ ├── index.d.ts │ │ └── menu │ │ │ ├── bubble.d.ts │ │ │ ├── edit.d.ts │ │ │ ├── index.d.ts │ │ │ ├── modal.d.ts │ │ │ └── static.d.ts │ ├── flow │ │ ├── flow-view.d.ts │ │ ├── flow.d.ts │ │ ├── index.d.ts │ │ └── menu │ │ │ ├── bubble.d.ts │ │ │ ├── constants.d.ts │ │ │ ├── edit.d.ts │ │ │ ├── index.d.ts │ │ │ ├── modal.d.ts │ │ │ └── static.d.ts │ ├── focus │ │ ├── focus.d.ts │ │ └── index.d.ts │ ├── gapcursor │ │ ├── gapcursor.d.ts │ │ └── index.d.ts │ ├── hard-break │ │ ├── hard-break.d.ts │ │ └── index.d.ts │ ├── heading │ │ ├── constants.d.ts │ │ ├── heading.d.ts │ │ ├── index.d.ts │ │ ├── menu.d.ts │ │ ├── slug.d.ts │ │ └── utilities.d.ts │ ├── horizontal-rule │ │ ├── horizontal-rule.d.ts │ │ ├── index.d.ts │ │ └── menu.d.ts │ ├── iframe │ │ ├── iframe-view.d.ts │ │ ├── iframe.d.ts │ │ ├── index.d.ts │ │ └── menu │ │ │ ├── bubble.d.ts │ │ │ ├── edit.d.ts │ │ │ ├── index.d.ts │ │ │ └── static.d.ts │ ├── image │ │ ├── image-view.d.ts │ │ ├── image.d.ts │ │ ├── index.d.ts │ │ └── menu │ │ │ ├── bubble-menu.d.ts │ │ │ ├── index.d.ts │ │ │ └── static-menu.d.ts │ ├── italic │ │ ├── index.d.ts │ │ ├── italic.d.ts │ │ └── menu.d.ts │ ├── link │ │ ├── index.d.ts │ │ ├── link.d.ts │ │ └── menu │ │ │ ├── bubble.d.ts │ │ │ ├── edit.d.ts │ │ │ ├── index.d.ts │ │ │ └── static.d.ts │ ├── list-item │ │ ├── index.d.ts │ │ └── list-item.d.ts │ ├── mention │ │ ├── index.d.ts │ │ ├── mention-menu-view.d.ts │ │ └── mention.d.ts │ ├── mind │ │ ├── index.d.ts │ │ ├── menu │ │ │ ├── bubble.d.ts │ │ │ ├── constant.d.ts │ │ │ ├── edit.d.ts │ │ │ ├── index.d.ts │ │ │ ├── kityminder │ │ │ │ ├── constant.d.ts │ │ │ │ └── index.d.ts │ │ │ ├── modal.d.ts │ │ │ ├── static.d.ts │ │ │ ├── style.d.ts │ │ │ └── toolbar │ │ │ │ ├── bgcolor.d.ts │ │ │ │ ├── font-color.d.ts │ │ │ │ ├── help.d.ts │ │ │ │ ├── image.d.ts │ │ │ │ ├── index.d.ts │ │ │ │ ├── link.d.ts │ │ │ │ ├── priority.d.ts │ │ │ │ ├── progress.d.ts │ │ │ │ ├── styled.d.ts │ │ │ │ ├── template.d.ts │ │ │ │ └── theme.d.ts │ │ ├── mind-view.d.ts │ │ └── mind.d.ts │ ├── ordered-list │ │ ├── index.d.ts │ │ └── ordered-list.d.ts │ ├── paragraph │ │ ├── index.d.ts │ │ └── paragraph.d.ts │ ├── placeholder │ │ ├── index.d.ts │ │ └── placeholder.d.ts │ ├── slash │ │ ├── index.d.ts │ │ ├── slash-menu-view.d.ts │ │ └── slash.d.ts │ ├── status │ │ ├── index.d.ts │ │ ├── menu.d.ts │ │ ├── status-view.d.ts │ │ └── status.d.ts │ ├── strike │ │ ├── index.d.ts │ │ ├── menu.d.ts │ │ └── strike.d.ts │ ├── subscript │ │ ├── index.d.ts │ │ ├── menu.d.ts │ │ └── subscript.d.ts │ ├── superscript │ │ ├── index.d.ts │ │ ├── menu.d.ts │ │ └── superscript.d.ts │ ├── table │ │ ├── cell-menu-plugin │ │ │ └── index.d.ts │ │ ├── index.d.ts │ │ ├── menu │ │ │ ├── bubble.d.ts │ │ │ ├── index.d.ts │ │ │ └── static.d.ts │ │ ├── table-cell │ │ │ └── index.d.ts │ │ ├── table-header │ │ │ └── index.d.ts │ │ ├── table-kit.d.ts │ │ ├── table-row │ │ │ └── index.d.ts │ │ ├── table │ │ │ ├── index.d.ts │ │ │ └── table-view.d.ts │ │ └── utilities │ │ │ └── index.d.ts │ ├── text-align │ │ ├── index.d.ts │ │ ├── menu.d.ts │ │ └── text-align.d.ts │ ├── text │ │ ├── index.d.ts │ │ └── text.d.ts │ ├── trailing-node │ │ ├── index.d.ts │ │ └── trailing-node.d.ts │ ├── underline │ │ ├── index.d.ts │ │ ├── menu.d.ts │ │ └── underline.d.ts │ └── unique-id │ │ ├── index.d.ts │ │ ├── unique-id.d.ts │ │ └── utilities │ │ ├── array-difference.d.ts │ │ ├── combine-transaction-steps.d.ts │ │ ├── find-duplicates.d.ts │ │ ├── get-changed-ranges.d.ts │ │ └── remove-duplicates.d.ts │ ├── hooks │ ├── index.d.ts │ ├── use-active.d.ts │ └── use-attributes.d.ts │ ├── icons │ ├── icon.d.ts │ └── index.d.ts │ ├── index.d.ts │ ├── prosemirror │ └── index.d.ts │ ├── stories │ ├── collaboration.stories.d.ts │ ├── content.d.ts │ └── editor.stories.d.ts │ ├── styles │ ├── editor.d.ts │ └── theme.d.ts │ └── utilities │ ├── clamp.d.ts │ ├── copy │ └── index.d.ts │ ├── create-node.d.ts │ ├── file.d.ts │ ├── index.d.ts │ ├── mark.d.ts │ ├── node.d.ts │ ├── pos.d.ts │ ├── svg-to-datauri.d.ts │ └── throttle.d.ts └── vite.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | end_of_line = lf 7 | charset = utf-8 8 | insert_final_newline = true 9 | max_line_length = 100 10 | trim_trailing_whitespace = true 11 | indent_style = space 12 | indent_size = 2 -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": [ 4 | "plugin:react/recommended", 5 | "plugin:@typescript-eslint/recommended", 6 | "prettier/@typescript-eslint", 7 | "plugin:prettier/recommended", 8 | "plugin:import/errors", 9 | "plugin:import/warnings", 10 | "plugin:import/typescript" 11 | ], 12 | "plugins": ["jsx-a11y"], 13 | "rules": { 14 | "eqeqeq": 2, 15 | "no-unused-vars": "off", 16 | "no-mixed-operators": "off", 17 | "jsx-a11y/href-no-hash": "off", 18 | "react/prop-types": "off", 19 | "@typescript-eslint/no-unused-vars": ["error"], 20 | "@typescript-eslint/explicit-function-return-type": "off", 21 | "@typescript-eslint/lines-between-class-members": ["error"], 22 | "@typescript-eslint/no-explicit-any": "off", 23 | "@typescript-eslint/no-empty-interface": "off", 24 | "@typescript-eslint/explicit-module-boundary-type": "off", 25 | "@typescript-eslint/no-var-requires": "off", 26 | "@typescript-eslint/ban-ts-comment": "off", 27 | "@typescript-eslint/ban-types": "off", 28 | "@typescript-eslint/consistent-type-imports": { "prefer": "type-imports" }, 29 | "@typescript-eslint/explicit-module-boundary-types": "off", 30 | "prettier/prettier": [ 31 | "error", 32 | { 33 | "printWidth": 80, 34 | "useTabs": false 35 | } 36 | ] 37 | }, 38 | "settings": { 39 | "react": { 40 | "version": "detect" 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .cache 3 | .DS_Store 4 | .temp 5 | node_modules 6 | dist 7 | .env 8 | .env.* 9 | .npmrc 10 | 11 | # Log files 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | 16 | # parcel-bundler cache (https://parceljs.org/) 17 | .cache 18 | 19 | .rpt2_cache 20 | .rts2_cache 21 | .rts2_cache_cjs 22 | .rts2_cache_es 23 | .rts2_cache_umd 24 | 25 | tests/cypress/videos 26 | /tests/cypress/screenshots 27 | # Ignore intellij project files 28 | .idea 29 | preview 30 | storybook-static -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | .test.js 3 | example 4 | .circleci 5 | .github 6 | .eslintignore 7 | .eslintrc 8 | .map 9 | dist/stories -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 80, //单行长度 3 | tabWidth: 2, //缩进长度 4 | useTabs: false, //使用空格代替tab缩进 5 | semi: true, //句末使用分号 6 | singleQuote: false, //使用单引号 7 | quoteProps: "as-needed", //仅在必需时为对象的key添加引号 8 | jsxSingleQuote: false, // jsx中使用单引号 9 | trailingComma: false, //多行时尽可能打印尾随逗号 10 | bracketSpacing: true, //在对象前后添加空格-eg: { foo: bar } 11 | jsxBracketSameLine: true, //多属性html标签的‘>’折行放置 12 | arrowParens: "avoid", //单参数箭头函数参数周围使用圆括号-eg: (x) => x 13 | requirePragma: false, //无需顶部注释即可格式化 14 | insertPragma: false, //在已被preitter格式化的文件顶部加上标注 15 | proseWrap: "preserve", //不知道怎么翻译 16 | htmlWhitespaceSensitivity: "ignore", //对HTML全局空白不敏感 17 | vueIndentScriptAndStyle: false, //不对vue中的script及style标签缩进 18 | endOfLine: "auto", //结束行形式 19 | embeddedLanguageFormatting: "auto" //对引用代码进行格式化 20 | }; 21 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: ["../src/**/*.stories.@(js|jsx|ts|tsx)"], 3 | addons: ["@storybook/addon-links", "@storybook/addon-essentials"], 4 | core: { 5 | builder: "webpack5" 6 | }, 7 | babel: async options => { 8 | const babelConfig = require("../babel.config.js"); 9 | return { ...options, ...babelConfig }; 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | export const parameters = { 2 | actions: { argTypesRegex: "^on[A-Z].*" }, 3 | controls: { 4 | matchers: { 5 | color: /(background|color)$/i, 6 | date: /Date$/, 7 | }, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MagicEditor 2 | 3 | > Just a rich text editor built on top of Prosemirror and Tiptap. 4 | 5 | ## Live Demo 6 | 7 | [Click here to see online demo](https://magic-editor.vercel.app/) 8 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | "@babel/preset-react", 4 | ["@babel/preset-env", { targets: { node: "current" } }], 5 | "@babel/preset-typescript" 6 | ], 7 | plugins: ["@emotion"] 8 | }; 9 | -------------------------------------------------------------------------------- /src/components/bubble-menu.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | BubbleMenu as BuiltInTiptapBubbleMenu, 3 | BubbleMenuProps as BuiltInTiptapBubbleMenuProps 4 | } from "@tiptap/react"; 5 | // 该 bubble-menu 经过改造后,在元素拖拽过程中不会消失 6 | import { BubbleMenu as NodeBubbleMenu } from "./react-bubble-menu"; 7 | import { Editor } from "@tiptap/core"; 8 | import { EditorState } from "prosemirror-state"; 9 | import { EditorView } from "prosemirror-view"; 10 | import React, { useMemo } from "react"; 11 | 12 | import { ZINDEX_DEFAULT } from "../constants"; 13 | 14 | const defaultTippyOptions: BuiltInTiptapBubbleMenuProps["tippyOptions"] = { 15 | maxWidth: 450, 16 | duration: 200, 17 | animation: "shift-toward-subtle", 18 | moveTransition: "transform 0.2s ease-in-out", 19 | zIndex: ZINDEX_DEFAULT, 20 | arrow: false, 21 | theme: "bubble-menu", 22 | showOnCreate: false, 23 | placement: "top" 24 | }; 25 | 26 | export type BubbleMenuProps = BuiltInTiptapBubbleMenuProps & { 27 | shouldShow: (props: { 28 | editor: Editor; 29 | view: EditorView; 30 | state: EditorState; 31 | oldState?: EditorState; 32 | from: number; 33 | to: number; 34 | }) => boolean; 35 | forNode?: boolean; 36 | }; 37 | 38 | export const BubbleMenu: React.FC = ({ 39 | editor, 40 | tippyOptions, 41 | forNode, 42 | children, 43 | ...rest 44 | }) => { 45 | const wrapTippyOptions = useMemo(() => { 46 | if (typeof tippyOptions === "object") { 47 | return { 48 | ...defaultTippyOptions, 49 | ...tippyOptions, 50 | theme: `bubble-menu ${tippyOptions.theme}`, 51 | appendTo: () => editor.options.element 52 | }; 53 | } 54 | 55 | return { ...defaultTippyOptions, appendTo: () => editor.options.element }; 56 | }, [editor, tippyOptions]); 57 | 58 | if (forNode) { 59 | return ( 60 | 61 | {children} 62 | 63 | ); 64 | } 65 | 66 | return ( 67 | <> 68 | 72 | {children} 73 | 74 | 75 | ); 76 | }; 77 | -------------------------------------------------------------------------------- /src/components/button.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | 4 | type Size = "small" | "normal" | "large"; 5 | const SizeMap: Record = { 6 | small: 4, 7 | normal: 6, 8 | large: 10 9 | }; 10 | 11 | type ButtonType = "primary" | "normal"; 12 | 13 | type Props = { 14 | active?: boolean; 15 | disabled?: boolean; 16 | icon?: React.ReactNode; 17 | size?: Size; 18 | type?: ButtonType; 19 | onClick?: () => void; 20 | style?: React.CSSProperties; 21 | }; 22 | 23 | type StyledButtonProps = Pick & { 24 | buttonType: ButtonType; 25 | }; 26 | 27 | const StyledButton = styled.button` 28 | margin: 0; 29 | 30 | display: inline-flex; 31 | justify-content: center; 32 | align-items: center; 33 | 34 | padding: ${props => (props.size ? SizeMap[props.size] : 6)}px; 35 | background: ${props => 36 | props.buttonType !== "normal" 37 | ? props.theme[props.buttonType!] 38 | : "transparent"}; 39 | border: 0 solid transparent; 40 | border-radius: 2px; 41 | 42 | box-shadow: none; 43 | outline: none; 44 | 45 | user-select: none; 46 | cursor: pointer; 47 | 48 | color: ${props => 49 | props.buttonType === "normal" ? "hsl(214deg 11% 12% / 80%)" : "#fff"}; 50 | font-size: 14px; 51 | font-weight: 600; 52 | 53 | vertical-align: middle; 54 | white-space: nowrap; 55 | 56 | &:hover { 57 | background-color: ${props => 58 | props.buttonType === "normal" 59 | ? "rgb(46 50 56 / 15%)" 60 | : props.theme[props.buttonType!]}; 61 | } 62 | 63 | ${props => 64 | props.active && 65 | `color: rgb(51 112 255); 66 | background: rgb(51 112 255 / 10%); 67 | `} 68 | `; 69 | 70 | export const Button: React.FC> = ({ 71 | active, 72 | disabled, 73 | icon, 74 | size = "normal", 75 | type = "normal", 76 | onClick, 77 | style, 78 | children 79 | }) => { 80 | return ( 81 | 88 | {icon || children} 89 | 90 | ); 91 | }; 92 | -------------------------------------------------------------------------------- /src/components/color-picker/style.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const StyledEmptyWrap = styled.div` 4 | display: flex; 5 | cursor: pointer; 6 | border: 1px solid transparent; 7 | flex-wrap: nowrap; 8 | 9 | &:hover { 10 | background-color: var(--semi-color-fill-1); 11 | } 12 | 13 | > span:first-child { 14 | position: relative; 15 | display: block; 16 | width: 20px; 17 | height: 20px; 18 | margin: 0 8px 0 1px; 19 | border: 1px solid #e8e8e8; 20 | border-radius: 2px; 21 | 22 | &::after { 23 | position: absolute; 24 | top: 8px; 25 | left: 0; 26 | display: block; 27 | width: 17px; 28 | height: 0; 29 | content: ""; 30 | transform: rotate(45deg); 31 | border-bottom: 2px solid #ff5151; 32 | } 33 | } 34 | `; 35 | 36 | export const StyledColorWrap = styled.div` 37 | display: flex; 38 | flex-wrap: wrap; 39 | margin-top: 8px; 40 | `; 41 | 42 | export const StyledColorItemWrap = styled.div` 43 | display: flex; 44 | width: 24px; 45 | height: 24px; 46 | cursor: pointer; 47 | border: 1px solid transparent; 48 | border-radius: 4px; 49 | justify-content: center; 50 | align-items: center; 51 | 52 | &:nth-of-type(n + 11) { 53 | margin-top: 4px; 54 | } 55 | 56 | &:hover { 57 | border-color: rgb(193 199 208); 58 | } 59 | 60 | > span { 61 | display: block; 62 | width: 20px; 63 | height: 20px; 64 | border: 1px solid var(--semi-color-border); 65 | border-radius: 2px; 66 | } 67 | `; 68 | -------------------------------------------------------------------------------- /src/components/divider.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | 4 | const StyledDiv = styled.div` 5 | display: inline-block; 6 | width: 1px; 7 | height: 18px; 8 | background: #dee0e3; 9 | `; 10 | 11 | export const _Divider = ({ vertical = false, margin = 2 }) => { 12 | return ( 13 | 19 | ); 20 | }; 21 | 22 | export const Divider = React.memo(_Divider, (prevProps, nextProps) => { 23 | return prevProps.vertical === nextProps.vertical; 24 | }); 25 | -------------------------------------------------------------------------------- /src/components/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { 4 | Row, 5 | Col, 6 | Input, 7 | Dropdown, 8 | Space, 9 | Popover, 10 | Tag, 11 | Modal, 12 | Toast 13 | } from "@douyinfe/semi-ui"; 14 | 15 | export * from "./bubble-menu"; 16 | export * from "./color-picker"; 17 | export * from "./tooltip"; 18 | export * from "./react-tooltip"; 19 | export * from "./button"; 20 | export * from "./divider"; 21 | export * from "./select"; 22 | export * from "./resizable"; 23 | export * from "./upload"; 24 | export * from "./loading"; 25 | 26 | export { Row, Col, Input, Dropdown, Space, Popover, Tag, Modal, Toast }; 27 | -------------------------------------------------------------------------------- /src/components/loading.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Spin } from "@douyinfe/semi-ui"; 3 | 4 | const LoadingWrapStyle: React.CSSProperties = { 5 | display: "flex", 6 | alignItems: "center", 7 | justifyContent: "center", 8 | flexDirection: "column" 9 | }; 10 | 11 | export const Loading = ({ text }: { text?: string }) => { 12 | return ( 13 |
14 | 15 |

{text}

16 |
17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /src/components/react-bubble-menu/bubble-menu-pluin/bubble-menu.ts: -------------------------------------------------------------------------------- 1 | import { Extension } from "@tiptap/core"; 2 | import { BubbleMenuPlugin, BubbleMenuPluginProps } from "./bubble-menu-plugin"; 3 | 4 | export type BubbleMenuOptions = Omit< 5 | BubbleMenuPluginProps, 6 | "editor" | "element" 7 | > & { 8 | element: HTMLElement | null; 9 | }; 10 | 11 | export const BubbleMenu = Extension.create({ 12 | name: "bubbleMenu", 13 | 14 | addOptions() { 15 | return { 16 | element: null, 17 | tippyOptions: {}, 18 | pluginKey: "bubbleMenu", 19 | shouldShow: null 20 | }; 21 | }, 22 | 23 | // @ts-ignore 24 | addProseMirrorPlugins() { 25 | if (!this.options.element) { 26 | return []; 27 | } 28 | 29 | return [ 30 | BubbleMenuPlugin({ 31 | pluginKey: this.options.pluginKey, 32 | editor: this.editor, 33 | element: this.options.element, 34 | tippyOptions: this.options.tippyOptions, 35 | shouldShow: this.options.shouldShow 36 | }) 37 | ]; 38 | } 39 | }); 40 | -------------------------------------------------------------------------------- /src/components/react-bubble-menu/bubble-menu-pluin/index.ts: -------------------------------------------------------------------------------- 1 | import { BubbleMenu } from "./bubble-menu"; 2 | 3 | export * from "./bubble-menu"; 4 | export * from "./bubble-menu-plugin"; 5 | 6 | export default BubbleMenu; 7 | -------------------------------------------------------------------------------- /src/components/react-bubble-menu/index.tsx: -------------------------------------------------------------------------------- 1 | import { BubbleMenuPlugin, BubbleMenuPluginProps } from "./bubble-menu-pluin"; 2 | import React, { useEffect, useState } from "react"; 3 | 4 | type Optional = Pick, K> & Omit; 5 | 6 | export type BubbleMenuProps = Omit< 7 | Optional, 8 | "element" 9 | > & { 10 | className?: string; 11 | children: React.ReactNode; 12 | }; 13 | 14 | export const BubbleMenu = (props: BubbleMenuProps) => { 15 | const [element, setElement] = useState(null); 16 | 17 | useEffect(() => { 18 | if (!element) { 19 | return; 20 | } 21 | 22 | if (props.editor.isDestroyed) { 23 | return; 24 | } 25 | 26 | const { 27 | pluginKey = "bubbleMenu", 28 | editor, 29 | tippyOptions = {}, 30 | shouldShow = null 31 | } = props; 32 | 33 | const plugin = BubbleMenuPlugin({ 34 | pluginKey, 35 | editor, 36 | element, 37 | tippyOptions, 38 | shouldShow 39 | }); 40 | 41 | // @ts-ignore 42 | editor.registerPlugin(plugin); 43 | 44 | // @ts-ignore 45 | return () => editor.unregisterPlugin(pluginKey); 46 | }, [props.editor, element]); 47 | 48 | return ( 49 |
53 | {props.children} 54 |
55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /src/components/react-tooltip.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { Editor } from "@tiptap/core"; 4 | import tippy, { Instance } from "tippy.js"; 5 | 6 | import { ZINDEX_DEFAULT } from "../constants"; 7 | import { Tooltip, Button } from "../components"; 8 | 9 | export const _ReactTooltip: React.FC<{ 10 | editor: Editor; 11 | title: string; 12 | icon: React.ReactNode; 13 | content: React.ReactNode; 14 | }> = ({ editor, title, icon, content }) => { 15 | const containerRef = useRef(null); 16 | const popupRef = useRef(null); 17 | 18 | useEffect(() => { 19 | const div = document.createElement("div"); 20 | 21 | ReactDOM.render(<>{content}, div); 22 | 23 | const popup: Instance[] = tippy("body", { 24 | getReferenceClientRect: () => { 25 | return (containerRef.current as HTMLElement).getBoundingClientRect(); 26 | }, 27 | appendTo: () => editor.options.element, 28 | content: div, 29 | showOnCreate: false, 30 | interactive: true, 31 | popperOptions: { 32 | strategy: "fixed" 33 | }, 34 | trigger: "manual", 35 | placement: "top-start", 36 | theme: "bubble-menu", 37 | arrow: false, 38 | zIndex: ZINDEX_DEFAULT 39 | }); 40 | 41 | popupRef.current = popup[0]; 42 | 43 | return () => { 44 | if (!popupRef.current) return; 45 | ReactDOM.unmountComponentAtNode(div); 46 | }; 47 | }, [editor, content]); 48 | 49 | return ( 50 | 51 | 52 | 35 | 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const PARSE_HTML_PRIORITY_LOWEST = 1; 2 | export const PARSE_HTML_PRIORITY_DEFAULT = 50; 3 | export const PARSE_HTML_PRIORITY_HIGHEST = 100; 4 | export const EXTENSION_PRIORITY_LOWER = 75; 5 | export const EXTENSION_PRIORITY_DEFAULT = 100; 6 | export const EXTENSION_PRIORITY_HIGHEST = 200; 7 | 8 | export const ZINDEX_DEFAULT = 10; 9 | export const ZINDEX_MIDDLE = 1500; 10 | export const ZINDEX_HIGHEST = 10000; 11 | 12 | export const MIN_ZOOM = 0.1; 13 | export const MAX_ZOOM = 2; 14 | export const ZOOM_STEP = 0.15; 15 | -------------------------------------------------------------------------------- /src/editor/collaboration.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useMemo } from "react"; 2 | import { Editor } from "@tiptap/core"; 3 | import { HocuspocusProvider } from "@hocuspocus/provider"; 4 | 5 | import { Collaboration } from "../extensions/collaboration"; 6 | import { CollaborationCursor } from "../extensions/collaboration-cursor"; 7 | 8 | import { EditorRender, EditorRenderProps } from "./render"; 9 | 10 | import { getUserColor } from "./utilities"; 11 | 12 | export interface CollaborationEditorProps extends EditorRenderProps { 13 | id: string; 14 | url: string; 15 | token: string; 16 | } 17 | 18 | export const CollaborationEditor = forwardRef< 19 | Editor | null, 20 | React.PropsWithChildren 21 | >((props, ref) => { 22 | const { id, url, token, extensions, userProvider, ...restProps } = props; 23 | 24 | const hocuspocusProvider = useMemo(() => { 25 | return new HocuspocusProvider({ 26 | url, 27 | name: id, 28 | token, 29 | maxAttempts: 1 30 | } as any); 31 | }, [id, url, token]); 32 | 33 | const currentUser = useMemo(() => { 34 | return userProvider.getCurrentUser() ?? {}; 35 | }, [userProvider]); 36 | 37 | if (!hocuspocusProvider) return
loading
; 38 | 39 | return ( 40 | 58 | ); 59 | }); 60 | 61 | CollaborationEditor.displayName = "CollaborationEditor"; 62 | -------------------------------------------------------------------------------- /src/editor/index.tsx: -------------------------------------------------------------------------------- 1 | export * from "./collaboration"; 2 | export * from "./render"; 3 | -------------------------------------------------------------------------------- /src/editor/kit.ts: -------------------------------------------------------------------------------- 1 | import { AnyExtension, Node } from "@tiptap/core"; 2 | 3 | import { Focus } from "../extensions/focus"; 4 | import { Loading } from "../extensions/loading"; 5 | import { Paragraph } from "../extensions/paragraph"; 6 | import { Text } from "../extensions/text"; 7 | import { HardBreak } from "../extensions/hard-break"; 8 | import { TrailingNode } from "../extensions/trailing-node"; 9 | import { Perf } from "../extensions/perf"; 10 | 11 | export interface EditorKit { 12 | schema: string; 13 | extensions: Array; 14 | } 15 | 16 | export const resolveEditorKit = (props: EditorKit) => { 17 | const { schema, extensions } = props; 18 | 19 | const Doc = Node.create({ 20 | name: "doc", 21 | topNode: true, 22 | content: schema 23 | }); 24 | 25 | const runtimeExtensions = [ 26 | Doc, 27 | Paragraph, 28 | Text, 29 | HardBreak, 30 | Focus, 31 | Loading, 32 | TrailingNode, 33 | Perf, 34 | ...extensions.flat() 35 | ]; 36 | 37 | return runtimeExtensions; 38 | }; 39 | -------------------------------------------------------------------------------- /src/editor/provider.ts: -------------------------------------------------------------------------------- 1 | import { Editor } from "@tiptap/core"; 2 | 3 | export interface User { 4 | id: string | number; 5 | name: string; 6 | avatar: string; 7 | [key: string]: unknown; 8 | } 9 | 10 | export interface EditorProvider { 11 | /** 12 | * 用户信息 13 | */ 14 | userProvider: { 15 | /** 16 | * 获取当前用户 17 | */ 18 | getCurrentUser: () => User; 19 | 20 | /** 21 | * 获取用户列表 22 | */ 23 | getUsers: (query: string) => Promise | User[]; 24 | }; 25 | /** 26 | * 文件上传 27 | */ 28 | fileProvider: { 29 | uploadFile: (file: Blob) => Promise; 30 | }; 31 | } 32 | 33 | export const getEditorProvider = (editor: Editor): EditorProvider => { 34 | // @ts-ignore 35 | return editor.options.editorProps.editorProvider; 36 | }; 37 | -------------------------------------------------------------------------------- /src/editor/render.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useImperativeHandle } from "react"; 2 | import { useEditor, EditorContent } from "@tiptap/react"; 3 | import { ThemeProvider } from "styled-components"; 4 | 5 | import { StyledEditor } from "../styles/editor"; 6 | 7 | import { resolveEditorKit, EditorKit } from "./kit"; 8 | import { EditorProvider } from "./provider"; 9 | import { Content, Editor } from "@tiptap/core"; 10 | import light from "../styles/theme"; 11 | 12 | export interface EditorRenderProps extends EditorProvider, EditorKit { 13 | content?: Content; 14 | } 15 | 16 | export const EditorRender = forwardRef< 17 | Editor | null, 18 | React.PropsWithChildren 19 | >((props, ref) => { 20 | const { 21 | schema, 22 | content, 23 | extensions, 24 | userProvider, 25 | fileProvider, 26 | children 27 | } = props; 28 | 29 | const editorProvider: EditorProvider = { 30 | userProvider, 31 | fileProvider 32 | }; 33 | 34 | const editor = useEditor( 35 | { 36 | content, 37 | extensions: resolveEditorKit({ schema, extensions }), 38 | editorProps: { 39 | attributes: { 40 | class: "magic-editor", 41 | spellcheck: "false", 42 | suppressContentEditableWarning: "true" 43 | }, 44 | // @ts-ignore 45 | editorProvider, 46 | theme: light 47 | }, 48 | onCreate(props) { 49 | props.editor.view.focus(); 50 | }, 51 | onUpdate(props) { 52 | // console.log(props.editor.getHTML()); 53 | } 54 | }, 55 | [] 56 | ); 57 | 58 | useImperativeHandle(ref, () => editor as Editor); 59 | 60 | return ( 61 | 62 |
{children}
63 | 64 | 65 | 66 |
67 | ); 68 | }); 69 | 70 | EditorRender.displayName = "EditorRender"; 71 | -------------------------------------------------------------------------------- /src/editor/theme.ts: -------------------------------------------------------------------------------- 1 | import { Editor } from "@tiptap/core"; 2 | 3 | export const getEditorTheme = (editor: Editor) => { 4 | // @ts-ignore 5 | return editor.options.editorProps.theme; 6 | }; 7 | -------------------------------------------------------------------------------- /src/editor/utilities.ts: -------------------------------------------------------------------------------- 1 | const colors = [ 2 | "#47A1FF", 3 | "#59CB74", 4 | "#FFB952", 5 | "#FC6980", 6 | "#6367EC", 7 | "#DA65CC", 8 | "#FBD54A", 9 | "#ADDF84", 10 | "#6CD3FF", 11 | "#659AEC", 12 | "#9F8CF1", 13 | "#ED8CCE", 14 | "#A2E5FF", 15 | "#4DCCCB", 16 | "#F79452", 17 | "#84E0BE", 18 | "#5982F6", 19 | "#E37474", 20 | "#3FDDC7", 21 | "#9861E5" 22 | ]; 23 | 24 | const total = colors.length; 25 | 26 | export const getUserColor = () => colors[~~(Math.random() * total)]; 27 | -------------------------------------------------------------------------------- /src/extensions/blockquote/blockquote.ts: -------------------------------------------------------------------------------- 1 | import { wrappingInputRule } from "@tiptap/core"; 2 | import { Blockquote as BuiltInBlockquote } from "@tiptap/extension-blockquote"; 3 | 4 | const multilineInputRegex = /^\s*>>>\s$/gm; 5 | 6 | export const Blockquote = BuiltInBlockquote.extend({ 7 | addInputRules() { 8 | return [ 9 | // eslint-disable-next-line no-unsafe-optional-chaining 10 | // @ts-ignore 11 | ...this.parent?.(), 12 | wrappingInputRule({ 13 | find: multilineInputRegex, 14 | type: this.type, 15 | getAttributes: () => ({ multiline: true }) 16 | }) 17 | ]; 18 | } 19 | }); 20 | -------------------------------------------------------------------------------- /src/extensions/blockquote/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./blockquote"; 2 | export * from "./menu"; 3 | -------------------------------------------------------------------------------- /src/extensions/blockquote/menu.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from "react"; 2 | import { Editor } from "@tiptap/core"; 3 | 4 | import { Tooltip } from "../../components/tooltip"; 5 | import { Button } from "../../components/button"; 6 | import { IconBlockquote } from "../../icons"; 7 | import { useActive } from "../../hooks/use-active"; 8 | 9 | import { Blockquote as BlockquoteExtension } from "./blockquote"; 10 | import i18n from "../../i18n"; 11 | 12 | export const BlockquoteStaticMenu: React.FC<{ editor: Editor }> = ({ 13 | editor 14 | }) => { 15 | const isBlockquoteActive = useActive(editor, BlockquoteExtension.name); 16 | 17 | const toggleBlockquote = useCallback( 18 | () => 19 | editor 20 | .chain() 21 | .focus() 22 | .toggleBlockquote() 23 | .run(), 24 | [editor] 25 | ); 26 | 27 | return ( 28 | 29 | 41 | 42 | }> 43 | 44 | 45 |