or Vue React component
+
+
+ // Insert mention node when emit some event.
+ function insertMention() {
+ const mentionNode: MentionElement = {
+ type: 'mention', // must be 'mention'
+ value: 'James', // text
+ info: { x: 1, y: 2 }, // extended info
+ children: [{ text: '' }], // must have an empty text node in children
+ }
+
+ editor.restoreSelection()
+ editor.deleteBackward('character') // delete '@'
+ editor.insertNode(mentionNode)
+ editor.move(1) // move curser
+ }
+}
+
+// hide your modal
+function hideModal(editor: IDomEditor) {
+ // hide your modal
+}
+
+// editor config
+const editorConfig: Partial
= {
+ EXTEND_CONF: {
+ mentionConfig: {
+ showModal, // required
+ hideModal, // required
+ },
+ },
+
+ // others...
+}
+
+// Then create editor and toolbar, you will use `editorConfig`
+```
+
+### Render HTML
+
+You will get a mention's HTML format like this. You need to `decodeURIComponent` the value of `data-info`.
+
+```html
+@James
+```
+
+
diff --git a/packages/plugin-mention/README.md b/packages/plugin-mention/README.md
new file mode 100644
index 000000000..28b49fb7e
--- /dev/null
+++ b/packages/plugin-mention/README.md
@@ -0,0 +1,87 @@
+# wangEditor mention 插件
+
+[English Documentation](./README-en.md)
+
+## 介绍
+
+[wangeditor-next](https://github.com/cycleccc/wangEditor-next) mention 插件,如 `@张三`。
+
+
+
+## 安装
+
+```shell
+yarn add @wangeditor-next/plugin-mention
+```
+
+## 使用
+
+[Vue 示例源码](https://github.com/wangfupeng1988/vue2-wangeditor-demo/blob/master/src/components/MyEditorWithMention.vue)
+
+### 注册到编辑器
+
+```ts
+import { IDomEditor, Boot, IEditorConfig } from '@wangeditor-next/editor'
+import mentionModule, { MentionElement } from '@wangeditor-next/plugin-mention'
+
+// 注册。要在创建编辑器之前注册,且只能注册一次,不可重复注册。
+Boot.registerModule(mentionModule)
+
+// 显示弹框
+function showModal(editor: IDomEditor) {
+ // 获取光标位置,定位 modal
+ const domSelection = document.getSelection()
+ const domRange = domSelection.getRangeAt(0)
+ if (domRange == null) return
+ const selectionRect = domRange.getBoundingClientRect()
+
+ // 获取编辑区域 DOM 节点的位置,以辅助定位
+ const containerRect = editor.getEditableContainer().getBoundingClientRect()
+
+ // 显示 modal 弹框,并定位
+ // PS:modal 需要自定义,如 或 Vue React 组件
+
+
+ // 当触发某事件(如点击一个按钮)时,插入 mention 节点
+ function insertMention() {
+ const mentionNode: MentionElement = {
+ type: 'mention', // 必须是 'mention'
+ value: '张三', // 文本
+ info: { x: 1, y: 2 }, // 其他信息,自定义
+ children: [{ text: '' }], // 必须有一个空 text 作为 children
+ }
+
+ editor.restoreSelection() // 恢复选区
+ editor.deleteBackward('character') // 删除 '@'
+ editor.insertNode(mentionNode) // 插入 mention
+ editor.move(1) // 移动光标
+ }
+}
+
+// 隐藏弹框
+function hideModal(editor: IDomEditor) {
+ // 隐藏 modal
+}
+
+// 编辑器配置
+const editorConfig: Partial = {
+ EXTEND_CONF: {
+ mentionConfig: {
+ showModal, // 必须
+ hideModal, // 必须
+ },
+ },
+
+ // 其他...
+}
+
+// 创建创建和工具栏,会用到 editorConfig 。具体查看 wangEditor 文档
+```
+
+### 显示 HTML
+
+mention 节点返回的 HTML 格式如下,其中 `data-info` 的值需要 `decodeURIComponent` 解析。
+
+```html
+@张三
+```
diff --git a/packages/plugin-mention/_img/demo.png b/packages/plugin-mention/_img/demo.png
new file mode 100644
index 000000000..9ede41d26
Binary files /dev/null and b/packages/plugin-mention/_img/demo.png differ
diff --git a/packages/plugin-mention/package.json b/packages/plugin-mention/package.json
new file mode 100644
index 000000000..f1fbf4083
--- /dev/null
+++ b/packages/plugin-mention/package.json
@@ -0,0 +1,47 @@
+{
+ "name": "@wangeditor-next/plugin-mention",
+ "version": "1.0.0",
+ "description": "wangEditor mention plugin",
+ "author": "cycleccc <2991205548@qq.com>",
+ "type": "module",
+ "homepage": "https://github.com/wangeditor-next/wangEditor-next#readme",
+ "license": "MIT",
+ "types": "dist/plugin-mention/src/index.d.ts",
+ "main": "dist/index.js",
+ "module": "dist/index.mjs",
+ "exports": {
+ ".": {
+ "types": "./dist/plugin-mention/src/index.d.ts",
+ "import": "./dist/index.mjs",
+ "require": "./dist/index.js"
+ },
+ "./dist/css/style.css": "./dist/css/style.css"
+ },
+ "directories": {
+ "lib": "dist"
+ },
+ "files": [
+ "dist"
+ ],
+ "publishConfig": {
+ "access": "public"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/wangeditor-next/wangEditor-next.git"
+ },
+ "scripts": {
+ "dev": "cross-env NODE_ENV=development rollup -c rollup.config.js",
+ "dev-watch": "cross-env NODE_ENV=development rollup -c rollup.config.js -w",
+ "build": "cross-env NODE_ENV=production rollup -c rollup.config.js",
+ "dev-size-stats": "cross-env NODE_ENV=development:size_stats rollup -c rollup.config.js",
+ "size-stats": "cross-env NODE_ENV=production:size_stats rollup -c rollup.config.js"
+ },
+ "bugs": {
+ "url": "https://github.com/wangeditor-next/wangeditor-next/issues"
+ },
+ "peerDependencies": {
+ "@wangeditor-next/editor": "5.6.31",
+ "snabbdom": "^3.1.0"
+ }
+}
diff --git a/packages/plugin-mention/rollup.config.js b/packages/plugin-mention/rollup.config.js
new file mode 100644
index 000000000..956adfb64
--- /dev/null
+++ b/packages/plugin-mention/rollup.config.js
@@ -0,0 +1,31 @@
+import { createRollupConfig } from '@wangeditor-next-shared/rollup-config'
+
+import pkg from './package.json' assert { type: 'json' }
+
+const name = 'WangEditorMentionPlugin'
+
+const configList = []
+
+// esm
+const esmConf = createRollupConfig({
+ output: {
+ file: pkg.module,
+ format: 'esm',
+ name,
+ },
+})
+
+configList.push(esmConf)
+
+// umd
+const umdConf = createRollupConfig({
+ output: {
+ file: pkg.main,
+ format: 'umd',
+ name,
+ },
+})
+
+configList.push(umdConf)
+
+export default configList
diff --git a/packages/plugin-mention/src/index.ts b/packages/plugin-mention/src/index.ts
new file mode 100644
index 000000000..f7a282bf4
--- /dev/null
+++ b/packages/plugin-mention/src/index.ts
@@ -0,0 +1,11 @@
+/**
+ * @description src entry
+ * @author wangfupeng
+ */
+
+import module from './module/index'
+
+export * from './module/custom-types'
+export * from './module/interface'
+
+export default module
diff --git a/packages/plugin-mention/src/module/custom-types.ts b/packages/plugin-mention/src/module/custom-types.ts
new file mode 100644
index 000000000..db6573c93
--- /dev/null
+++ b/packages/plugin-mention/src/module/custom-types.ts
@@ -0,0 +1,14 @@
+/**
+ * @description mention element
+ * @author wangfupeng
+ */
+
+type EmptyText = {
+ text: ''
+}
+export type MentionElement = {
+ type: 'mention'
+ value: string
+ info: any
+ children: EmptyText[] // void 元素必须有一个空 text
+}
diff --git a/packages/plugin-mention/src/module/elem-to-html.ts b/packages/plugin-mention/src/module/elem-to-html.ts
new file mode 100644
index 000000000..02ae55fe0
--- /dev/null
+++ b/packages/plugin-mention/src/module/elem-to-html.ts
@@ -0,0 +1,24 @@
+/**
+ * @description elem to html
+ * @author wangfupeng
+ */
+
+import { SlateElement } from '@wangeditor-next/editor'
+
+import { MentionElement } from './custom-types'
+
+// 生成 html 的函数
+function mentionToHtml(elem: SlateElement, _childrenHtml: string): string {
+ const { value = '', info = {} } = elem as MentionElement
+ const infoStr = encodeURIComponent(JSON.stringify(info))
+
+ return `@${value}`
+}
+
+// 配置
+const conf = {
+ type: 'mention', // 节点 type ,重要!!!
+ elemToHtml: mentionToHtml,
+}
+
+export default conf
diff --git a/packages/plugin-mention/src/module/index.ts b/packages/plugin-mention/src/module/index.ts
new file mode 100644
index 000000000..8fc77d11d
--- /dev/null
+++ b/packages/plugin-mention/src/module/index.ts
@@ -0,0 +1,20 @@
+/**
+ * @description mention module entry
+ * @author wangfupeng
+ */
+
+import { IModuleConf } from '@wangeditor-next/editor'
+
+import elemToHtmlConf from './elem-to-html'
+import parseHtmlConf from './parse-elem-html'
+import withMention from './plugin'
+import renderElemConf from './render-elem'
+
+const module: Partial = {
+ editorPlugin: withMention,
+ renderElems: [renderElemConf],
+ elemsToHtml: [elemToHtmlConf],
+ parseElemsHtml: [parseHtmlConf],
+}
+
+export default module
diff --git a/packages/plugin-mention/src/module/interface.ts b/packages/plugin-mention/src/module/interface.ts
new file mode 100644
index 000000000..d6580dd4b
--- /dev/null
+++ b/packages/plugin-mention/src/module/interface.ts
@@ -0,0 +1,13 @@
+/**
+ * @description interface
+ * @author wangfupeng
+ */
+
+import { IDomEditor } from '@wangeditor-next/editor'
+
+export interface IExtendConfig {
+ mentionConfig: {
+ showModal: (editor: IDomEditor) => void
+ hideModal: (editor: IDomEditor) => void
+ }
+}
diff --git a/packages/plugin-mention/src/module/parse-elem-html.ts b/packages/plugin-mention/src/module/parse-elem-html.ts
new file mode 100644
index 000000000..968cbef2c
--- /dev/null
+++ b/packages/plugin-mention/src/module/parse-elem-html.ts
@@ -0,0 +1,41 @@
+/**
+ * @description parse elem html
+ * @author wangfupeng
+ */
+
+import { IDomEditor, SlateDescendant, SlateElement } from '@wangeditor-next/editor'
+
+import { DOMElement } from '../utils/dom'
+import { MentionElement } from './custom-types'
+
+function parseHtml(
+ elem: DOMElement,
+ _children: SlateDescendant[],
+ _editor: IDomEditor,
+): SlateElement {
+ // elem HTML 结构 @张三
+
+ const value = elem.getAttribute('data-value') || ''
+ const rawInfo = decodeURIComponent(elem.getAttribute('data-info') || '')
+ let info: any
+
+ try {
+ info = JSON.parse(rawInfo)
+ } catch (ex) {
+ info = rawInfo
+ }
+
+ return {
+ type: 'mention',
+ value,
+ info,
+ children: [{ text: '' }], // void node 必须有一个空白 text
+ } as MentionElement
+}
+
+const parseHtmlConf = {
+ selector: 'span[data-w-e-type="mention"]',
+ parseElemHtml: parseHtml,
+}
+
+export default parseHtmlConf
diff --git a/packages/plugin-mention/src/module/plugin.ts b/packages/plugin-mention/src/module/plugin.ts
new file mode 100644
index 000000000..7b77d2957
--- /dev/null
+++ b/packages/plugin-mention/src/module/plugin.ts
@@ -0,0 +1,91 @@
+/**
+ * @description mention plugin
+ * @author wangfupeng
+ */
+
+import { DomEditor, IDomEditor } from '@wangeditor-next/editor'
+
+import { IExtendConfig } from './interface'
+
+function getMentionConfig(editor: IDomEditor) {
+ const { EXTEND_CONF } = editor.getConfig()
+ const { mentionConfig } = EXTEND_CONF as IExtendConfig
+
+ return mentionConfig
+}
+
+function withMention(editor: T) {
+ const { insertText, isInline, isVoid } = editor
+ const newEditor = editor
+
+ // 重写 insertText
+ newEditor.insertText = t => {
+ // 选过选中了 void 元素
+ const elems = DomEditor.getSelectedElems(newEditor)
+ const isSelectedVoidElem = elems.some(elem => newEditor.isVoid(elem))
+
+ if (isSelectedVoidElem) {
+ insertText(t)
+ return
+ }
+
+ // mention 相关配置
+ const { showModal, hideModal } = getMentionConfig(newEditor)
+
+ if (t === '@') {
+ setTimeout(() => {
+ // 展示 modal (异步,以便准确获取光标位置)
+ if (showModal) { showModal(newEditor) }
+
+ // 监听,隐藏 modal(异步,等待 modal 渲染后再监听)
+ setTimeout(() => {
+ function hide() {
+ if (hideModal) { hideModal(newEditor) }
+ }
+ newEditor.once('fullScreen', hide)
+ newEditor.once('unFullScreen', hide)
+ newEditor.once('scroll', hide)
+ newEditor.once('modalOrPanelShow', hide)
+ newEditor.once('modalOrPanelHide', hide)
+
+ function hideOnChange() {
+ if (newEditor.selection != null) {
+ hide()
+ newEditor.off('change', hideOnChange) // 及时解绑
+ }
+ }
+ newEditor.on('change', hideOnChange)
+ })
+ })
+ }
+
+ // 非 '@' 则执行默认行为
+ insertText(t)
+ }
+
+ // 重写 isInline
+ newEditor.isInline = elem => {
+ const type = DomEditor.getNodeType(elem)
+
+ if (type === 'mention') {
+ return true
+ }
+
+ return isInline(elem)
+ }
+
+ // 重写 isVoid
+ newEditor.isVoid = elem => {
+ const type = DomEditor.getNodeType(elem)
+
+ if (type === 'mention') {
+ return true
+ }
+
+ return isVoid(elem)
+ }
+
+ return newEditor
+}
+
+export default withMention
diff --git a/packages/plugin-mention/src/module/render-elem.ts b/packages/plugin-mention/src/module/render-elem.ts
new file mode 100644
index 000000000..b2e68ecdf
--- /dev/null
+++ b/packages/plugin-mention/src/module/render-elem.ts
@@ -0,0 +1,45 @@
+/**
+ * @description render elem
+ * @author wangfupeng
+ */
+
+import { DomEditor, IDomEditor, SlateElement } from '@wangeditor-next/editor'
+import { h, VNode } from 'snabbdom'
+
+import { MentionElement } from './custom-types'
+
+function renderMention(elem: SlateElement, _children: VNode[] | null, editor: IDomEditor): VNode {
+ // 当前节点是否选中
+ const selected = DomEditor.isNodeSelected(editor, elem)
+ const { value = '' } = elem as MentionElement
+
+ // 构建 vnode
+ const vnode = h(
+ 'span',
+ {
+ props: {
+ contentEditable: false, // 不可编辑
+ },
+ style: {
+ marginLeft: '3px',
+ marginRight: '3px',
+ backgroundColor: 'var(--w-e-textarea-slight-bg-color)',
+ border: selected // 选中/不选中,样式不一样
+ ? '2px solid var(--w-e-textarea-selected-border-color)' // wangEditor 提供了 css var https://www.wangeditor.com/v5/theme.html
+ : '2px solid transparent',
+ borderRadius: '3px',
+ padding: '0 3px',
+ },
+ },
+ `@${value}`, // 如 `@张三`
+ )
+
+ return vnode
+}
+
+const conf = {
+ type: 'mention', // 节点 type ,重要!!!
+ renderElem: renderMention,
+}
+
+export default conf
diff --git a/packages/plugin-mention/src/utils/dom.ts b/packages/plugin-mention/src/utils/dom.ts
new file mode 100644
index 000000000..0456ac1b5
--- /dev/null
+++ b/packages/plugin-mention/src/utils/dom.ts
@@ -0,0 +1,19 @@
+/**
+ * @description dom utils
+ * @author wangfupeng
+ */
+
+// COMPAT: This is required to prevent TypeScript aliases from doing some very
+// weird things for Slate's types with the same name as globals. (2019/11/27)
+// https://github.com/microsoft/TypeScript/issues/35002
+import DOMNode = globalThis.Node
+import DOMComment = globalThis.Comment
+import DOMElement = globalThis.Element
+import DOMText = globalThis.Text
+import DOMRange = globalThis.Range
+import DOMSelection = globalThis.Selection
+import DOMStaticRange = globalThis.StaticRange
+
+export {
+ DOMComment, DOMElement, DOMNode, DOMRange, DOMSelection, DOMStaticRange, DOMText,
+}
diff --git a/packages/plugin-mention/tsconfig.json b/packages/plugin-mention/tsconfig.json
new file mode 100644
index 000000000..9bef938c9
--- /dev/null
+++ b/packages/plugin-mention/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "compilerOptions": {},
+ "extends": "../../tsconfig.json",
+ "include": [
+ "./src/**/*",
+ "../custom-types.d.ts"
+ ]
+}
\ No newline at end of file
diff --git a/yarn.lock b/yarn.lock
index e78c3ea97..a155646aa 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3670,6 +3670,15 @@ __metadata:
languageName: unknown
linkType: soft
+"@wangeditor-next/plugin-mention@workspace:packages/plugin-mention":
+ version: 0.0.0-use.local
+ resolution: "@wangeditor-next/plugin-mention@workspace:packages/plugin-mention"
+ peerDependencies:
+ "@wangeditor-next/editor": 5.6.31
+ snabbdom: ^3.1.0
+ languageName: unknown
+ linkType: soft
+
"@wangeditor-next/table-module@npm:~1.6.42, @wangeditor-next/table-module@workspace:packages/table-module":
version: 0.0.0-use.local
resolution: "@wangeditor-next/table-module@workspace:packages/table-module"