diff --git a/.changeset/wise-ducks-grin.md b/.changeset/wise-ducks-grin.md new file mode 100644 index 000000000..72a6df5a8 --- /dev/null +++ b/.changeset/wise-ducks-grin.md @@ -0,0 +1,5 @@ +--- +'@wangeditor-next/plugin-mention': patch +--- + +feat(plugin-mention): add mention plugin diff --git a/packages/plugin-mention/CHANGELOG.md b/packages/plugin-mention/CHANGELOG.md new file mode 100644 index 000000000..e69de29bb diff --git a/packages/plugin-mention/README-en.md b/packages/plugin-mention/README-en.md new file mode 100644 index 000000000..2d543df29 --- /dev/null +++ b/packages/plugin-mention/README-en.md @@ -0,0 +1,90 @@ +# wangEditor mention plugin + +[中文文档](./README.md) + +## Introduction + +[wangeditor-next](https://github.com/cycleccc/wangEditor-next) mention plugin, like `@James`. + +![](./_img/demo.png) + +## Installation + +```shell +yarn add @wangeditor-next/plugin-mention +``` + +## Usage + +[Vue demo source code](https://github.com/wangfupeng1988/vue2-wangeditor-demo/blob/master/src/components/MyEditorWithMention.vue) + +### Use in editor + +```ts +import { IDomEditor, Boot, IEditorConfig } from '@wangeditor-next/editor' +import mentionModule, { MentionElement } from '@wangeditor-next/plugin-mention' + +// Register +// You should register this before create editor, and register only once (not repeatedly). +Boot.registerModule(mentionModule) + +// Show your modal +function showModal(editor: IDomEditor) { + // Get cursor's position info, to set modal position + const domSelection = document.getSelection() + const domRange = domSelection.getRangeAt(0) + if (domRange == null) return + const selectionRect = domRange.getBoundingClientRect() + + // Get editor container's position info, maybe help to get right modal position + const containerRect = editor.getEditableContainer().getBoundingClientRect() + + // Show your modal, and set position + // PS: You must implement the modal yourself, use
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 插件,如 `@张三`。 + +![](./_img/demo.png) + +## 安装 + +```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"