hello') }) }) ================================================ FILE: packages/basic-modules/__tests__/blockquote/parse-html.test.ts ================================================ /** * @description parse html test * @author wangfupeng */ import { $ } from 'dom7' import { BaseElement } from 'slate' import createEditor from '../../../../tests/utils/create-editor' import { parseHtmlConf } from '../../src/modules/blockquote/parse-elem-html' describe('blockquote - parse html', () => { const editor = createEditor() it('without children', () => { const $elem = $('
hello world') // match selector expect($elem[0].matches(parseHtmlConf.selector)).toBeTruthy() // parse const res = parseHtmlConf.parseElemHtml($elem[0], [], editor) expect(res).toEqual({ type: 'blockquote', children: [{ text: 'hello world' }], }) }) it('with children', () => { const $elem = $('') const children = [{ text: 'hello ' }, { text: 'world', bold: true }] // parse const res = parseHtmlConf.parseElemHtml($elem[0], children, editor) expect(res).toEqual({ type: 'blockquote', children: [{ text: 'hello ' }, { text: 'world', bold: true }], }) }) it('with inline children', () => { const $elem = $('') const children: any[] = [ { text: 'hello ' }, { type: 'link', url: 'http://wangeditor.com' }, { type: 'paragraph', children: [] }, ] const isInline = editor.isInline editor.isInline = (element: any) => { if (element.type === 'link') return true return isInline(element) } // parse const res = parseHtmlConf.parseElemHtml($elem[0], children, editor) expect(res).toEqual({ type: 'blockquote', children: [{ text: 'hello ' }, { type: 'link', url: 'http://wangeditor.com' }], }) }) }) ================================================ FILE: packages/basic-modules/__tests__/blockquote/plugin.test.ts ================================================ /** * @description blockquote plugin test * @author wangfupeng */ import { Editor, Transforms } from 'slate' import createEditor from '../../../../tests/utils/create-editor' import withBlockquote from '../../src/modules/blockquote/plugin' describe('blockquote plugin', () => { let editor = withBlockquote(createEditor()) let startLocation = Editor.start(editor, []) beforeEach(() => { editor = withBlockquote(createEditor()) startLocation = Editor.start(editor, []) }) it('insert break', () => { expect(1).toBeTruthy() // TODO 该测试一直报错(找不到 blockquote path),待定处理 // editor.select(startLocation) // // @ts-ignore // Transforms.setNodes(editor, { type: 'blockquote' }) // 设置 blockquote // const pList1 = editor.getElemsByType('paragraph') // expect(pList1.length).toBe(0) // editor.insertText('hello') // console.log(11, JSON.stringify(editor.children)) // console.log(22, JSON.stringify(editor.selection)) // editor.insertBreak() // 第一次换行,内部插入 \n // const pList2 = editor.getElemsByType('paragraph') // expect(pList2.length).toBe(0) // editor.insertBreak() // 再一次换行,生成 p // const pList3 = editor.getElemsByType('paragraph') // expect(pList3.length).toBe(1) }) }) ================================================ FILE: packages/basic-modules/__tests__/blockquote/render-elem.test.ts ================================================ /** * @description blockquote render elem test * @author wangfupeng */ import createEditor from '../../../../tests/utils/create-editor' import { renderBlockQuoteConf } from '../../src/modules/blockquote/render-elem' describe('blockquote - render elem', () => { const editor = createEditor() it('render blockquote elem', () => { expect(renderBlockQuoteConf.type).toBe('blockquote') const elem = { type: 'blockquote', children: [] } const vnode = renderBlockQuoteConf.renderElem(elem, null, editor) expect(vnode.sel).toBe('blockquote') }) }) ================================================ FILE: packages/basic-modules/__tests__/code-block/code-block-menu.test.ts ================================================ /** * @description code-block menu test * @author wangfupeng */ import { Editor, Transforms, Element } from 'slate' import createEditor from '../../../../tests/utils/create-editor' import CodeBlockMenu from '../../src/modules/code-block/menu/CodeBlockMenu' describe('code-block menu', () => { const menu = new CodeBlockMenu() let editor: any let startLocation: any const codeElem = { type: 'code', language: 'javascript', children: [{ text: 'var' }], } const preElem = { type: 'pre', children: [codeElem], } beforeEach(() => { editor = createEditor() startLocation = Editor.start(editor, []) }) afterEach(() => { editor = null startLocation = null }) it('getValue and isActive', done => { editor.select(startLocation) expect(menu.isActive(editor)).toBeFalsy() expect(menu.getValue(editor)).toBe('') editor.insertNode(preElem) // 插入 code node editor.select({ path: [1, 0, 0], // 选中 code node offset: 3, }) setTimeout(() => { expect(menu.isActive(editor)).toBeTruthy() expect(menu.getValue(editor)).toBe('javascript') done() }) }) it('is disabled', () => { editor.select(startLocation) expect(menu.isDisabled(editor)).toBeFalsy() Transforms.setNodes(editor, { type: 'header1' } as Partial
hello')
})
it('pre to html', () => {
expect(preToHtmlConf.type).toBe('pre')
const elem = { type: 'pre', children: [] }
const html = preToHtmlConf.elemToHtml(elem, 'hello')
expect(html).toBe('hello') }) }) ================================================ FILE: packages/basic-modules/__tests__/code-block/parse-html.test.ts ================================================ /** * @description parse elem html * @author wangfupeng */ import { $ } from 'dom7' import createEditor from '../../../../tests/utils/create-editor' import { parseCodeHtmlConf, parsePreHtmlConf } from '../../src/modules/code-block/parse-elem-html' import { preParseHtmlConf } from '../../src/modules/code-block/pre-parse-html' describe('code block - pre parse html', () => { it('pre parse html', () => { const $pre = $('') const $code = $('
var a = 100; ')
$pre.append($code)
// match selector
expect($code[0].matches(preParseHtmlConf.selector)).toBeTruthy()
// pre parse
const res = preParseHtmlConf.preParseHtml($code[0])
expect(res.innerHTML).toBe('var a = 100;')
})
})
describe('code block - parse html', () => {
const editor = createEditor()
it('parse code html', () => {
const $pre = $('')
const $code = $('var a = 100; ')
$pre.append($code)
// match selector
expect($code[0].matches(parseCodeHtmlConf.selector)).toBeTruthy()
// parse
const res = parseCodeHtmlConf.parseElemHtml($code[0], [], editor)
expect(res).toEqual({
type: 'code',
language: '',
children: [{ text: 'var a = 100;' }],
})
})
it('parse pre html', () => {
const $pre = $('')
const children = [
{
type: 'code',
language: '',
children: [{ text: 'var a = 100;' }],
},
]
// match selector
expect($pre[0].matches(parsePreHtmlConf.selector)).toBeTruthy()
// parse
const res = parsePreHtmlConf.parseElemHtml($pre[0], children, editor)
expect(res).toEqual({
type: 'pre',
children: [
{
type: 'code',
language: '',
children: [{ text: 'var a = 100;' }],
},
],
})
})
})
================================================
FILE: packages/basic-modules/__tests__/code-block/plugin.test.ts
================================================
/**
* @description code-block plugin test
* @author wangfupeng
*/
import { Editor, Transforms } from 'slate'
import createEditor from '../../../../tests/utils/create-editor'
import withCodeBlock from '../../src/modules/code-block/plugin'
// 模拟 DataTransfer
class MyDataTransfer {
private values: object = {}
setData(type: string, value: string) {
this.values[type] = value
}
getData(type: string): string {
return this.values[type]
}
}
describe('code-block plugin', () => {
let editor: any
let startLocation: any
const codeElem = {
type: 'code',
children: [{ text: 'var' }],
}
const preElem = {
type: 'pre',
children: [codeElem],
}
beforeEach(() => {
editor = withCodeBlock(createEditor())
startLocation = Editor.start(editor, [])
})
afterEach(() => {
editor = null
startLocation = null
})
it('insert break', () => {
editor.select(startLocation)
editor.insertNode(preElem) // 插入 code-block
// code-block 前后会自动生成两个 p
const pList1 = editor.getElemsByTypePrefix('paragraph')
expect(pList1.length).toBe(2)
editor.select({
path: [1, 0, 0], // 选中 code-block
offset: 3,
})
// 换行都在 code-block 内部
editor.insertBreak()
editor.insertBreak()
editor.insertBreak()
expect(editor.getText()).toBe('\nvar\n\n\n\n')
// 不会再生成新的 p
const pList2 = editor.getElemsByTypePrefix('paragraph')
expect(pList2.length).toBe(2)
})
it('insert data', () => {
editor.select(startLocation)
editor.insertNode(preElem) // 插入 code node
editor.select({
path: [1, 0, 0], // 选中 code node
offset: 3,
})
const data = new MyDataTransfer()
data.setData('text/plain', ' hello')
editor.insertData(data)
expect(editor.getText()).toBe('\nvar hello\n')
})
it('normalizeNode - code node 不能是顶级元素,否则替换为 p', () => {
editor.select(startLocation)
editor.insertNode(codeElem)
const pList = editor.getElemsByTypePrefix('paragraph')
expect(pList.length).toBe(2)
})
it('normalizeNode - pre node 不能是第一个节点,否则前面插入 p', () => {
editor.select(startLocation)
editor.insertNode({ type: 'pre', children: [{ type: 'code', children: [{ text: 'var' }] }] })
const pList = editor.getElemsByTypePrefix('paragraph')
expect(pList.length).toBe(2)
const preList = editor.getElemsByTypePrefix('pre')
expect(preList.length).toBe(1)
})
})
================================================
FILE: packages/basic-modules/__tests__/code-block/render-elem.test.ts
================================================
/**
* @description code-block render elem test
* @author wangfupeng
*/
import createEditor from '../../../../tests/utils/create-editor'
import { renderPreConf, renderCodeConf } from '../../src/modules/code-block/render-elem'
describe('code-block render elem', () => {
const editor = createEditor()
it('render code elem', () => {
expect(renderCodeConf.type).toBe('code')
const elem = { type: 'code', children: [] }
const vnode = renderCodeConf.renderElem(elem, null, editor)
expect(vnode.sel).toBe('code')
})
it('render pre elem', () => {
expect(renderPreConf.type).toBe('pre')
const elem = { type: 'pre', children: [] }
const vnode = renderPreConf.renderElem(elem, null, editor)
expect(vnode.sel).toBe('pre')
})
})
================================================
FILE: packages/basic-modules/__tests__/color/color-menus.test.ts
================================================
/**
* @description color menus test
* @author wangfupeng
*/
import { Editor, Transforms } from 'slate'
import createEditor from '../../../../tests/utils/create-editor'
import ColorMenu from '../../src/modules/color/menu/ColorMenu'
import BgColorMenu from '../../src/modules/color/menu/BgColorMenu'
describe('color menus', () => {
let editor: any
let startLocation: any
const menus = [
{
mark: 'color',
menu: new ColorMenu(),
},
{
mark: 'bgColor',
menu: new BgColorMenu(),
},
]
beforeEach(() => {
editor = createEditor()
startLocation = Editor.start(editor, [])
})
afterEach(() => {
editor = null
startLocation = null
})
// exec 无代码,不用测试
it('getValue and isActive', () => {
editor.select(startLocation)
menus.forEach(({ menu }) => {
expect(menu.getValue(editor)).toBe('')
expect(menu.isActive(editor)).toBeFalsy()
})
editor.insertText('hello') // 插入文字
editor.select([]) // 全选
menus.forEach(({ mark, menu }) => {
editor.addMark(mark, 'rgb(51, 51, 51)') // 添加 color bgColor
expect(menu.getValue(editor)).toBe('rgb(51, 51, 51)')
expect(menu.isActive(editor)).toBeTruthy()
})
})
it('is disabled', () => {
editor.select(startLocation)
menus.forEach(({ menu }) => {
expect(menu.isDisabled(editor)).toBeFalsy()
})
editor.insertNode({ type: 'pre', children: [{ type: 'code', children: [{ text: 'var' }] }] })
menus.forEach(({ menu }) => {
expect(menu.isDisabled(editor)).toBeTruthy()
})
// Transforms.removeNodes(editor, { mode: 'highest' }) // 移除 pre/code
})
it('get panel content elem', () => {
menus.forEach(({ menu }) => {
const elem = menu.getPanelContentElem(editor)
expect(elem instanceof HTMLElement).toBeTruthy()
})
})
})
================================================
FILE: packages/basic-modules/__tests__/color/parse-html.test.ts
================================================
/**
* @description parse html test
* @author wangfupeng
*/
import { $ } from 'dom7'
import createEditor from '../../../../tests/utils/create-editor'
import { parseStyleHtml } from '../../src/modules/color/parse-style-html'
import { preParseHtmlConf } from '../../src/modules/color/pre-parse-html'
describe('color - pre parse html', () => {
it('pre parse html', () => {
const $font = $('hello')
// match selector
expect($font[0].matches(preParseHtmlConf.selector)).toBeTruthy()
// pre parse
const res = preParseHtmlConf.preParseHtml($font[0])
expect(res.outerHTML).toBe('hello')
})
})
describe('color - parse style html', () => {
const editor = createEditor()
it('parse style html', () => {
const $span = $(
''
)
const textNode = { text: 'hello' }
// parse style
const res = parseStyleHtml($span[0], textNode, editor)
expect(res).toEqual({
text: 'hello',
color: 'rgb(235, 144, 58)',
bgColor: 'rgb(231, 246, 213)',
})
})
})
================================================
FILE: packages/basic-modules/__tests__/color/render-text-style.test.tsx
================================================
/**
* @description color - render text style test
* @author wangfupeng
*/
import { jsx } from 'snabbdom'
import { renderStyle } from '../../src/modules/color/render-style'
describe('color - render text style', () => {
it('render color style', () => {
const color = 'rgb(51, 51, 51)'
const bgColor = 'rgb(204, 204, 204)'
const textNode = { text: 'hello', color, bgColor }
const vnode = hello
// @ts-ignore
const newVnode = renderStyle(textNode, vnode) as any
expect(newVnode.sel).toBe('span')
expect(newVnode.data.style.color).toBe(color)
expect(newVnode.data.style.backgroundColor).toBe(bgColor)
})
})
================================================
FILE: packages/basic-modules/__tests__/color/text-style-to-html.test.ts
================================================
/**
* @description color - text style to html test
* @author wangfupeng
*/
import { styleToHtml } from '../../src/modules/color/style-to-html'
describe('color - text style to html', () => {
it('color to html', () => {
const color = 'rgb(51, 51, 51)'
const bgColor = 'rgb(204, 204, 204)'
const textNode = { text: '', color, bgColor }
const html = styleToHtml(textNode, 'hello')
expect(html).toBe(`hello`)
})
})
================================================
FILE: packages/basic-modules/__tests__/divider/elem-to-html.test.ts
================================================
/**
* @description divider - elem to html test
* @author wangfupeng
*/
import { dividerToHtmlConf } from '../../src/modules/divider/elem-to-html'
describe('divider - elem to html', () => {
it('divider to html', () => {
expect(dividerToHtmlConf.type).toBe('divider')
const elem = { type: 'divider', children: [{ text: '' }] }
const html = dividerToHtmlConf.elemToHtml(elem, '')
expect(html).toBe('
'
)
// match selector
expect($img[0].matches(parseHtmlConf.selector)).toBeTruthy()
// parse
const res = parseHtmlConf.parseElemHtml($img[0], [], editor)
expect(res).toEqual({
type: 'image',
src: 'hello.png',
alt: 'hello',
href: 'http://localhost/',
style: {
width: '10px',
height: '5px',
},
children: [{ text: '' }],
})
})
})
================================================
FILE: packages/basic-modules/__tests__/image/plugin.test.ts
================================================
/**
* @description image plugin test
* @author wangfupeng
*/
import createEditor from '../../../../tests/utils/create-editor'
import withImage from '../../src/modules/image/plugin'
describe('image plugin', () => {
const editor = withImage(createEditor())
const elem = { type: 'image', children: [{ text: '' }] }
it('image is inline', () => {
expect(editor.isInline(elem)).toBeTruthy()
})
it('image is void', () => {
expect(editor.isVoid(elem)).toBeTruthy()
})
})
================================================
FILE: packages/basic-modules/__tests__/image/render-elem.test.ts
================================================
/**
* @description image - render elem test
* @author wangfupeng
*/
import { Editor } from 'slate'
import { renderImageConf } from '../../src/modules/image/render-elem'
import createEditor from '../../../../tests/utils/create-editor'
describe('image render elem', () => {
let editor: any
let startLocation: any
beforeEach(() => {
editor = createEditor()
startLocation = Editor.start(editor, [])
})
afterEach(() => {
editor.clear()
editor.destroy()
editor = null
startLocation = null
})
it('render image - unselected image', () => {
expect(renderImageConf.type).toBe('image')
const src = 'https://www.wangeditor.com/imgs/logo.png'
const href = 'https://www.wangeditor.com/'
const elem = {
type: 'image',
src,
alt: 'logo',
href,
style: { width: '100', height: '80' },
children: [{ text: '' }], // void node 必须包含一个空 text
}
const containerVnode = renderImageConf.renderElem(elem, null, editor) as any
expect(containerVnode.sel).toBe('div')
expect(containerVnode.data.className).toBe('w-e-image-container')
expect(containerVnode.data.style.width).toBe('100')
expect(containerVnode.data.style.height).toBe('80')
const imageVnode = containerVnode.children[0] as any
expect(imageVnode.sel).toBe('img')
expect(imageVnode.data.src).toBe(src)
expect(imageVnode.data['data-href']).toBe(href)
})
it('render image - selected image', () => {
const src = 'https://www.wangeditor.com/imgs/logo.png'
const href = 'https://www.wangeditor.com/'
const elem = {
type: 'image',
src,
alt: 'logo',
href,
style: { width: '100', height: '80' },
children: [{ text: '' }], // void node 必须包含一个空 text
}
editor.select(startLocation)
editor.insertNode(elem) // 插入图片
editor.select({
path: [0, 1, 0], // 选中图片
offset: 0,
})
const containerVnode = renderImageConf.renderElem(elem, null, editor) as any
expect(containerVnode.sel).toBe('div')
expect(containerVnode.data.className.indexOf('w-e-selected-image-container')).toBeGreaterThan(0)
expect(containerVnode.children.length).toBe(5) // image + 4 个拖拽触手
})
})
================================================
FILE: packages/basic-modules/__tests__/indent/menu/decrease-indent-menu.test.ts
================================================
/**
* @description decrease indent menu test
* @author wangfupeng
*/
import { Editor, Transforms } from 'slate'
import createEditor from '../../../../../tests/utils/create-editor'
import DecreaseIndentMenu from '../../../src/modules/indent/menu/DecreaseIndentMenu'
describe('decrease indent menu', () => {
let editor: any
let startLocation: any
const menu = new DecreaseIndentMenu()
beforeEach(() => {
editor = createEditor()
startLocation = Editor.start(editor, [])
})
afterEach(() => {
editor = null
startLocation = null
})
it('is disabled', () => {
editor.select(startLocation)
expect(menu.isDisabled(editor)).toBeTruthy() // 没有 indent 则 disabled
Transforms.setNodes(editor, { type: 'header1', children: [] })
expect(menu.isDisabled(editor)).toBeTruthy() // 没有 indent 则 disabled
editor.insertNode({ type: 'pre', children: [{ type: 'code', children: [{ text: 'var' }] }] })
expect(menu.isDisabled(editor)).toBeTruthy() // 除了 p header 之外,其他 type 不可用 indent
// Transforms.removeNodes(editor, { mode: 'highest' }) // 移除 pre/code
})
// isActive 不用测试
// getValue 在 increase menu 已测试过
it('exec', () => {
editor.select(startLocation)
Transforms.setNodes(editor, { type: 'paragraph', indent: '2em', children: [] })
expect(menu.isDisabled(editor)).toBeFalsy() // 有 indent 则取消 disabled
menu.exec(editor, '')
expect(menu.getValue(editor)).toBe('')
})
})
================================================
FILE: packages/basic-modules/__tests__/indent/menu/increase-indent-menu.test.ts
================================================
/**
* @description increase indent menu test
* @author wangfupeng
*/
import { Editor, Transforms } from 'slate'
import createEditor from '../../../../../tests/utils/create-editor'
import IncreaseIndentMenu from '../../../src/modules/indent/menu/IncreaseIndentMenu'
describe('increase indent menu', () => {
let editor: any
let startLocation: any
const menu = new IncreaseIndentMenu()
beforeEach(() => {
editor = createEditor()
startLocation = Editor.start(editor, [])
})
afterEach(() => {
editor = null
startLocation = null
})
it('is disabled', () => {
editor.select(startLocation)
expect(menu.isDisabled(editor)).toBeFalsy()
Transforms.setNodes(editor, { type: 'header1', children: [] })
expect(menu.isDisabled(editor)).toBeFalsy()
editor.insertNode({ type: 'pre', children: [{ type: 'code', children: [{ text: 'var' }] }] })
expect(menu.isDisabled(editor)).toBeTruthy() // 除了 p header 之外,其他 type 不可用 indent
// Transforms.removeNodes(editor, { mode: 'highest' }) // 移除 pre/code
})
// isActive 不用测试
it('exec and getValue', () => {
editor.select(startLocation)
expect(menu.getValue(editor)).toBe('')
menu.exec(editor, '')
expect(menu.getValue(editor)).toBe('2em')
})
it('indent value', () => {
editor.insertNode({
type: 'paragraph',
children: [{ fontSize: '18px', text: 'text1' } as any],
})
menu.exec(editor, '')
expect(menu.getValue(editor)).toBe('36px')
})
})
================================================
FILE: packages/basic-modules/__tests__/indent/parse-html.test.ts
================================================
/**
* @description parse html test
* @author wangfupeng
*/
import { $ } from 'dom7'
import createEditor from '../../../../tests/utils/create-editor'
import { parseStyleHtml } from '../../src/modules/indent/parse-style-html'
import { preParseHtmlConf } from '../../src/modules/indent/pre-parse-html'
describe('indent - parse style', () => {
const editor = createEditor()
it('parse style', () => {
const $p = $('')
const paragraph = { type: 'paragraph', children: [{ text: 'hello' }] }
// parse
const res = parseStyleHtml($p[0], paragraph, editor)
expect(res).toEqual({
type: 'paragraph',
indent: '2em',
children: [{ text: 'hello' }],
})
})
})
describe('indent - pre parse html', () => {
it('pre parse', () => {
expect(preParseHtmlConf.selector).toBe('p,h1,h2,h3,h4,h5')
const $p = $('')
// parse
const res = preParseHtmlConf.preParseHtml($p[0])
expect((res as HTMLParagraphElement).style.textIndent).toBe('2em')
})
it('pre parse with px unit', () => {
expect(preParseHtmlConf.selector).toBe('p,h1,h2,h3,h4,h5')
const $p = $('')
// parse
const res = preParseHtmlConf.preParseHtml($p[0])
expect((res as HTMLParagraphElement).style.textIndent).toBe('2em')
})
})
================================================
FILE: packages/basic-modules/__tests__/indent/render-text-style.test.tsx
================================================
/**
* @description indent - render text style
* @author wangfupeng
*/
import { jsx } from 'snabbdom'
import { renderStyle } from '../../src/modules/indent/render-style'
describe('indent - render text style', () => {
it('render text style', () => {
const indent = '2em'
const elem = { type: 'paragraph', indent, children: [] }
const vnode = hello
// @ts-ignore const newVnode = renderStyle(elem, vnode) // @ts-ignore expect(newVnode.data.style.textIndent).toBe(indent) }) }) ================================================ FILE: packages/basic-modules/__tests__/indent/text-style-to-html.test.ts ================================================ /** * @description indent - text style to html test * @author wangfupeng */ import { styleToHtml } from '../../src/modules/indent/style-to-html' describe('indent - text style to html', () => { it('text style to html', () => { const indent = '2em' const elem = { type: 'paragraph', indent, children: [] } const html = styleToHtml(elem, 'hello
') expect(html).toBe(`hello
`) }) }) ================================================ FILE: packages/basic-modules/__tests__/justify/menus.test.ts ================================================ /** * @description justify menus test * @author wangfupeng */ import { Editor, Transforms } from 'slate' import createEditor from '../../../../tests/utils/create-editor' import JustifyCenterMenu from '../../src/modules/justify/menu/JustifyCenterMenu' import JustifyJustifyMenu from '../../src/modules/justify/menu/JustifyJustifyMenu' import JustifyLeftMenu from '../../src/modules/justify/menu/JustifyLeftMenu' import JustifyRightMenu from '../../src/modules/justify/menu/JustifyRightMenu' describe('justify menus', () => { let editor: any let startLocation: any const centerMenu = new JustifyCenterMenu() const justifyMenu = new JustifyJustifyMenu() const leftMenu = new JustifyLeftMenu() const rightMenu = new JustifyRightMenu() beforeEach(() => { editor = createEditor() startLocation = Editor.start(editor, []) }) afterEach(() => { editor = null startLocation = null }) // getValue getActive 不需要测试 it('is disabled', () => { editor.deselect() expect(centerMenu.isDisabled(editor)).toBeTruthy() editor.select(startLocation) expect(centerMenu.isDisabled(editor)).toBeFalsy() editor.insertNode({ type: 'pre', children: [{ type: 'code', children: [{ text: 'var' }] }] }) expect(centerMenu.isDisabled(editor)).toBeTruthy() // Transforms.removeNodes(editor, { mode: 'highest' }) // 移除 pre/code }) it('exec', () => { editor.select(startLocation) centerMenu.exec(editor, '') const p1 = editor.getElemsByTypePrefix('paragraph')[0] expect(p1.textAlign).toBe('center') justifyMenu.exec(editor, '') const p2 = editor.getElemsByTypePrefix('paragraph')[0] expect(p2.textAlign).toBe('justify') leftMenu.exec(editor, '') const p3 = editor.getElemsByTypePrefix('paragraph')[0] expect(p3.textAlign).toBe('left') rightMenu.exec(editor, '') const p4 = editor.getElemsByTypePrefix('paragraph')[0] expect(p4.textAlign).toBe('right') }) }) ================================================ FILE: packages/basic-modules/__tests__/justify/parse-html.test.ts ================================================ /** * @description parse html test * @author wangfupeng */ import { $ } from 'dom7' import createEditor from '../../../../tests/utils/create-editor' import { parseStyleHtml } from '../../src/modules/justify/parse-style-html' describe('text align - parse style', () => { const editor = createEditor() it('parse style', () => { const $p = $('') const paragraph = { type: 'paragraph', children: [{ text: 'hello' }] } // parse const res = parseStyleHtml($p[0], paragraph, editor) expect(res).toEqual({ type: 'paragraph', textAlign: 'center', children: [{ text: 'hello' }], }) }) }) ================================================ FILE: packages/basic-modules/__tests__/justify/render-text-style.test.tsx ================================================ /** * @description justify - render text style test * @author wangfupeng */ import { jsx } from 'snabbdom' import { renderStyle } from '../../src/modules/justify/render-style' describe('justify - render text style', () => { it('render text style', () => { const elem = { type: 'paragraph', textAlign: 'center', children: [] } const vnode = hello // @ts-ignore 忽略 vnode 格式 const newVnode = renderStyle(elem, vnode) // @ts-ignore 忽略 vnode 格式 expect(newVnode.data.style?.textAlign).toBe('center') }) }) ================================================ FILE: packages/basic-modules/__tests__/justify/text-style-to-html.test.ts ================================================ /** * @description justify - text style to html test * @author wangfupeng */ import { styleToHtml } from '../../src/modules/justify/style-to-html' describe('justify text-style-to-html', () => { it('text style to html', () => { const elem = { type: 'paragraph', textAlign: 'center', children: [] } const html = styleToHtml(elem, 'hello') expect(html).toBe('hello') }) }) ================================================ FILE: packages/basic-modules/__tests__/line-height/line-height-menu.test.ts ================================================ /** * @description line-height menu test * @author wangfupeng */ import { Editor, Transforms } from 'slate' import createEditor from '../../../../tests/utils/create-editor' import LineHeightMenu from '../../src/modules/line-height/menu/LineHeightMenu' describe('line-height menu', () => { let editor: any let startLocation: any const menu = new LineHeightMenu() beforeEach(() => { editor = createEditor() startLocation = Editor.start(editor, []) }) afterEach(() => { editor = null startLocation = null }) it('get options', () => { editor.select(startLocation) const options = menu.getOptions(editor) expect(options.length).toBeGreaterThan(0) // 默认选中 空 const selectedEmptyOne = options.some(opt => opt.value === '' && opt.selected) expect(selectedEmptyOne).toBe(true) }) // isActive 返回 false ,不用测试 it('get value', () => { editor.select(startLocation) expect(menu.getValue(editor)).toBe('') // 设置 lineHeight Transforms.setNodes(editor, { lineHeight: '1.5' }, { mode: 'highest' }) expect(menu.getValue(editor)).toBe('1.5') }) it('is disable', () => { editor.deselect() expect(menu.isDisabled(editor)).toBeTruthy() editor.select(startLocation) expect(menu.isDisabled(editor)).toBeFalsy() Transforms.setNodes(editor, { type: 'header1' }) expect(menu.isDisabled(editor)).toBeFalsy() Transforms.setNodes(editor, { type: 'blockquote' }) expect(menu.isDisabled(editor)).toBeFalsy() Transforms.setNodes(editor, { type: 'list-item' }) expect(menu.isDisabled(editor)).toBeFalsy() editor.insertNode({ type: 'pre', children: [{ type: 'code', children: [{ text: 'var' }] }] }) expect(menu.isDisabled(editor)).toBeTruthy() // Transforms.removeNodes(editor, { mode: 'highest' }) // 移除 pre/code }) it('exec', () => { editor.select(startLocation) menu.exec(editor, '1.5') expect(menu.getValue(editor)).toBe('1.5') }) }) ================================================ FILE: packages/basic-modules/__tests__/line-height/parse-html.test.ts ================================================ /** * @description parse html test * @author wangfupeng */ import { $ } from 'dom7' import createEditor from '../../../../tests/utils/create-editor' import { parseStyleHtml } from '../../src/modules/line-height/parse-style-html' describe('line height - parse style', () => { const editor = createEditor() it('parse style', () => { const $p = $('') const paragraph = { type: 'paragraph', children: [{ text: 'hello' }] } // parse const res = parseStyleHtml($p[0], paragraph, editor) expect(res).toEqual({ type: 'paragraph', lineHeight: '2.5', children: [{ text: 'hello' }], }) }) }) ================================================ FILE: packages/basic-modules/__tests__/line-height/render-text-style.test.tsx ================================================ /** * @description line-height render text style test * @author wangfupeng */ import { jsx } from 'snabbdom' import { renderStyle } from '../../src/modules/line-height/render-style' describe('line-height render-text-style', () => { it('render text style', () => { const elem = { type: 'paragraph', lineHeight: '1.5', children: [] } const vnode = hello // @ts-ignore 忽略 vnode 格式检查 const newVnode = renderStyle(elem, vnode) // @ts-ignore 忽略 vnode 格式检查 expect(newVnode.data.style.lineHeight).toBe('1.5') }) }) ================================================ FILE: packages/basic-modules/__tests__/line-height/text-style-to-html.test.ts ================================================ /** * @description line-height text-style-to-html test * @author wangfupeng */ import { styleToHtml } from '../../src/modules/line-height/style-to-html' describe('line-height text-style-to-html', () => { it('text style to html', () => { const elem = { type: 'paragraph', lineHeight: '1.5', children: [] } const html = styleToHtml(elem, 'hello') expect(html).toBe('hello') }) }) ================================================ FILE: packages/basic-modules/__tests__/link/elem-to-html.test.ts ================================================ /** * @description link - elem to html test * @author wangfupeng */ import { linkToHtmlConf } from '../../src/modules/link/elem-to-html' describe('link elem to html', () => { it('link to html', () => { expect(linkToHtmlConf.type).toBe('link') const url = 'https://www.wangeditor.com/' const target = '_blank' const elem = { type: 'link', url, target, children: [] } const html = linkToHtmlConf.elemToHtml(elem, 'hello') expect(html).toBe(`hello`) }) }) ================================================ FILE: packages/basic-modules/__tests__/link/helper.test.ts ================================================ /** * @description link module helper test * @author wangfupeng */ import { Editor, Transforms } from 'slate' import createEditor from '../../../../tests/utils/create-editor' import { isMenuDisabled, insertLink, updateLink } from '../../src/modules/link/helper' describe('link module helper', () => { let editor: any let startLocation: any beforeEach(() => { editor = createEditor() startLocation = Editor.start(editor, []) }) afterEach(() => { editor = null startLocation = null }) it('menu disable', () => { editor.deselect() expect(isMenuDisabled(editor)).toBeTruthy() editor.select(startLocation) expect(isMenuDisabled(editor)).toBeFalsy() editor.insertNode({ type: 'link', url: 'https://www.wangeditor.com/', children: [{ text: 'xxx' }], }) expect(isMenuDisabled(editor)).toBeTruthy() // 选中 link ,则禁用 editor.clear() editor.insertNode({ type: 'pre', children: [ { type: 'code', children: [{ text: 'var' }], }, ], }) expect(isMenuDisabled(editor)).toBeTruthy() // 选中 code-block ,则禁用 }) it('insert link with collapsed selection', async () => { editor.select(startLocation) const url = 'https://www.wangeditor.com/' await insertLink(editor, 'hello', url) const links = editor.getElemsByTypePrefix('link') expect(links.length).toBe(1) const linkElem = links[0] expect(linkElem.url).toBe(url) }) it('insert link with expand selection', async () => { editor.select(startLocation) editor.insertText('hello') Transforms.move(editor, { distance: 3, // 选中 3 个字母 unit: 'character', }) editor.select([]) // 全选 const url = 'https://www.wangeditor.com/' await insertLink(editor, 'hello', url) const links = editor.getElemsByTypePrefix('link') expect(links.length).toBe(1) const linkElem = links[0] expect(linkElem.url).toBe(url) }) it('update link', async () => { editor.select(startLocation) const url = 'https://www.wangeditor.com/' await insertLink(editor, 'hello', url) // 选区移动到 link 内部 editor.select({ path: [0, 1, 0], offset: 3, }) // 更新链接 const newUrl = 'https://www.wangeditor.com/123' await updateLink(editor, '', newUrl) const links = editor.getElemsByTypePrefix('link') expect(links.length).toBe(1) const linkElem = links[0] expect(linkElem.url).toBe(newUrl) }) }) ================================================ FILE: packages/basic-modules/__tests__/link/menu/edit-link-menu.test.ts ================================================ /** * @description edit link menu test * @author wangfupeng */ import { Editor } from 'slate' import createEditor from '../../../../../tests/utils/create-editor' import EditLink from '../../../src/modules/link/menu/EditLink' describe('edit link menu', () => { let editor: any let startLocation: any const menu = new EditLink() const linkNode = { type: 'link', url: 'https://www.wangeditor.com/', children: [{ text: 'xxx' }], } beforeEach(() => { editor = createEditor() startLocation = Editor.start(editor, []) }) afterEach(() => { editor = null startLocation = null }) it('get value', () => { editor.select(startLocation) expect(menu.getValue(editor)).toBe('') editor.insertNode(linkNode) editor.select({ path: [0, 1, 0], // 选区定位到 link 内部 offset: 1, }) expect(menu.getValue(editor)).toBe(linkNode.url) }) it('is active', () => { expect(menu.isActive(editor)).toBeFalsy() }) it('is disable', () => { editor.select(startLocation) expect(menu.isDisabled(editor)).toBeTruthy() editor.insertNode(linkNode) editor.select({ path: [0, 1, 0], // 选区定位到 link 内部 offset: 1, }) expect(menu.isDisabled(editor)).toBeFalsy() }) it('get modal position node', () => { editor.select(startLocation) expect(menu.getModalPositionNode(editor)).toBeNull() editor.insertNode(linkNode) editor.select({ path: [0, 1, 0], // 选区定位到 link 内部 offset: 1, }) const node = menu.getModalPositionNode(editor) as any expect(node.type).toBe('link') expect(node.url).toBe(linkNode.url) }) it('get modal content elem', () => { editor.select(startLocation) const elem = menu.getModalContentElem(editor) expect(elem.tagName).toBe('DIV') }) }) ================================================ FILE: packages/basic-modules/__tests__/link/menu/insert-link-menu.test.ts ================================================ /** * @description insert link menu test * @author wangfupeng */ import { Editor } from 'slate' import createEditor from '../../../../../tests/utils/create-editor' import InsertLinkMenu from '../../../src/modules/link/menu/InsertLink' describe('insert link menu', () => { const editor = createEditor() const menu = new InsertLinkMenu() const startLocation = Editor.start(editor, []) afterEach(() => { editor.select(startLocation) editor.clear() editor.deselect() }) it('get value', () => { expect(menu.getValue(editor)).toBe('') }) it('is active', () => { expect(menu.isActive(editor)).toBeFalsy() }) it('get modal position node', () => { expect(menu.getModalPositionNode(editor)).toBeNull() }) it('is disable', () => { editor.select(startLocation) expect(menu.isDisabled(editor)).toBeFalsy() }) it('get modal content elem', () => { const elem = menu.getModalContentElem(editor) expect(elem.tagName).toBe('DIV') }) }) ================================================ FILE: packages/basic-modules/__tests__/link/menu/unlink-menu.test.ts ================================================ /** * @description unlink menu test * @author wangfupeng */ import { Editor } from 'slate' import createEditor from '../../../../../tests/utils/create-editor' import UnLink from '../../../src/modules/link/menu/UnLink' describe('unlink menu test', () => { let editor: any let startLocation: any const menu = new UnLink() const linkNode = { type: 'link', url: 'https://www.wangeditor.com/', children: [{ text: 'xxx' }], } beforeEach(() => { editor = createEditor() startLocation = Editor.start(editor, []) }) afterEach(() => { editor = null startLocation = null }) it('get value', () => { expect(menu.getValue(editor)).toBe('') }) it('is active', () => { expect(menu.isActive(editor)).toBe(false) }) it('is disable', () => { editor.select(startLocation) expect(menu.isDisabled(editor)).toBeTruthy() editor.insertNode(linkNode) editor.select({ path: [0, 1, 0], // 选区定位到 link 内部 offset: 1, }) expect(menu.isDisabled(editor)).toBeFalsy() }) it('exec', () => { editor.select(startLocation) editor.insertNode(linkNode) editor.select({ path: [0, 1, 0], // 选区定位到 link 内部 offset: 1, }) menu.exec(editor, '') const links = editor.getElemsByTypePrefix('link') expect(links.length).toBe(0) }) }) ================================================ FILE: packages/basic-modules/__tests__/link/menu/view-link-menu.test.ts ================================================ /** * @description view link menu test * @author wangfupeng */ import { Editor } from 'slate' import createEditor from '../../../../../tests/utils/create-editor' import ViewLink from '../../../src/modules/link/menu/ViewLink' describe('view link menu', () => { let editor: any let startLocation: any const menu = new ViewLink() const linkNode = { type: 'link', url: 'https://www.wangeditor.com/', children: [{ text: 'xxx' }], } beforeEach(() => { editor = createEditor() startLocation = Editor.start(editor, []) }) afterEach(() => { editor = null startLocation = null }) it('get value', () => { editor.select(startLocation) expect(menu.getValue(editor)).toBe('') editor.insertNode(linkNode) editor.select({ path: [0, 1, 0], // 选区定位到 link 内部 offset: 1, }) expect(menu.getValue(editor)).toBe(linkNode.url) }) it('is active', () => { expect(menu.isActive(editor)).toBe(false) }) it('is disable', () => { editor.select(startLocation) expect(menu.isDisabled(editor)).toBeTruthy() editor.insertNode(linkNode) editor.select({ path: [0, 1, 0], // 选区定位到 link 内部 offset: 1, }) expect(menu.isDisabled(editor)).toBeFalsy() }) }) ================================================ FILE: packages/basic-modules/__tests__/link/parse-html.test.ts ================================================ /** * @description parse html test * @author wangfupeng */ import { $ } from 'dom7' import createEditor from '../../../../tests/utils/create-editor' import { parseHtmlConf } from '../../src/modules/link/parse-elem-html' describe('link - parse html', () => { const editor = createEditor() it('without children', () => { const $link = $('hello world') // match selector expect($link[0].matches(parseHtmlConf.selector)).toBeTruthy() // parse const res = parseHtmlConf.parseElemHtml($link[0], [], editor) expect(res).toEqual({ type: 'link', url: 'http://localhost/', target: '_blank', children: [{ text: 'hello world' }], }) }) it('with children', () => { const $link = $('') const children = [{ text: 'hello ' }, { text: 'world', bold: true }] // match selector expect($link[0].matches(parseHtmlConf.selector)).toBeTruthy() // parse const res = parseHtmlConf.parseElemHtml($link[0], children, editor) expect(res).toEqual({ type: 'link', url: 'http://localhost/', target: '_blank', children: [{ text: 'hello ' }, { text: 'world', bold: true }], }) }) }) ================================================ FILE: packages/basic-modules/__tests__/link/plugin.test.ts ================================================ /** * @description link plugin test * @author wangfupeng */ import { Editor } from 'slate' import withLink from '../../src/modules/link/plugin' import createEditor from '../../../../tests/utils/create-editor' // 模拟 DataTransfer class MyDataTransfer { private values: object = {} setData(type: string, value: string) { this.values[type] = value } getData(type: string): string { return this.values[type] } } describe('link plugin', () => { const editor = withLink(createEditor()) const startLocation = Editor.start(editor, []) it('link is inline elem', () => { const elem = { type: 'link', children: [] } expect(editor.isInline(elem)).toBeTruthy() }) it('link insert data', done => { const url = 'https://www.wangeditor.com/' const data = new MyDataTransfer() data.setData('text/plain', url) editor.select(startLocation) // @ts-ignore editor.insertData(data) setTimeout(() => { const links = editor.getElemsByTypePrefix('link') expect(links.length).toBe(1) const linkElem = links[0] as any expect(linkElem.url).toBe(url) done() }) }) }) ================================================ FILE: packages/basic-modules/__tests__/link/render-elem.test.ts ================================================ /** * @description link - render elem test * @author wangfupeng */ import createEditor from '../../../../tests/utils/create-editor' import { renderLinkConf } from '../../src/modules/link/render-elem' describe('link render elem', () => { const editor = createEditor() it('render elem', () => { expect(renderLinkConf.type).toBe('link') const url = 'https://www.wangeditor.com/' const target = '_blank' const elem = { type: 'link', url, target, children: [] } const vnode = renderLinkConf.renderElem(elem, null, editor) as any expect(vnode.sel).toBe('a') expect(vnode.data.href).toBe(url) expect(vnode.data.target).toBe(target) }) }) ================================================ FILE: packages/basic-modules/__tests__/paragraph/elem-to-html.test.ts ================================================ import { html } from 'dom7' /** * @description paragraph - elem to html test * @author wangfupeng */ import { pToHtmlConf } from '../../src/modules/paragraph/elem-to-html' describe('paragraph - elem to html', () => { it('paragraph to html', () => { expect(pToHtmlConf.type).toBe('paragraph') const elem = { type: 'paragraph', children: [] } const html = pToHtmlConf.elemToHtml(elem, 'hello') expect(html).toBe('hello
') }) it('paragraph to html with empty children', () => { expect(pToHtmlConf.type).toBe('paragraph') const elem = { type: 'paragraph', children: [] } const html = pToHtmlConf.elemToHtml(elem, '') expect(html).toBe('hello world
') // match selector expect($elem[0].matches(parseParagraphHtmlConf.selector)).toBeTruthy() // parse const res = parseParagraphHtmlConf.parseElemHtml($elem[0], [], editor) expect(res).toEqual({ type: 'paragraph', children: [{ text: 'hello world' }], }) }) it('with children', () => { const $elem = $('') const children = [{ text: 'hello ' }, { text: 'world', bold: true }] // parse const res = parseParagraphHtmlConf.parseElemHtml($elem[0], children, editor) expect(res).toEqual({ type: 'paragraph', children: [{ text: 'hello ' }, { text: 'world', bold: true }], }) }) }) ================================================ FILE: packages/basic-modules/__tests__/paragraph/plugin.test.ts ================================================ /** * @description paragraph plugin test * @author wangfupeng */ import { Editor, Transforms, Point } from 'slate' import { DomEditor, IDomEditor } from '@wangeditor/core' import createEditor from '../../../../tests/utils/create-editor' import withParagraph from '../../src/modules/paragraph/plugin' let editor: IDomEditor let startLocation: Point describe('paragraph plugin', () => { beforeEach(() => { editor = withParagraph(createEditor()) startLocation = Editor.start(editor, []) }) it('delete to clear text', () => { editor.select(startLocation) Transforms.setNodes(editor, { type: 'header1' }) // 设置 header editor.deleteBackward('character') // 向后删除 const selectedParagraph1 = DomEditor.getSelectedNodeByType(editor, 'paragraph') expect(selectedParagraph1).not.toBeNull() // 执行删除后,header 变为 paragraph Transforms.setNodes(editor, { type: 'blockquote' }) // 设置 blockquote editor.deleteForward('character') // 向前删除 const selectedParagraph2 = DomEditor.getSelectedNodeByType(editor, 'paragraph') expect(selectedParagraph2).not.toBeNull() // 执行删除后,header 变为 paragraph }) }) ================================================ FILE: packages/basic-modules/__tests__/paragraph/render-elem.test.ts ================================================ /** * @description paragraph render elem test * @author wangfupeng */ import createEditor from '../../../../tests/utils/create-editor' import { renderParagraphConf } from '../../src/modules/paragraph/render-elem' describe('paragraph - render elem', () => { const editor = createEditor() it('render paragraph', () => { expect(renderParagraphConf.type).toBe('paragraph') const elem = { type: 'paragraph', children: [] } const vnode = renderParagraphConf.renderElem(elem, null, editor) expect(vnode.sel).toBe('p') }) }) ================================================ FILE: packages/basic-modules/__tests__/text-style/menu/clear-style-menu.test.ts ================================================ /** * @description clear style menu test * @author wangfupeng */ import { Editor } from 'slate' import createEditor from '../../../../../tests/utils/create-editor' import ClearStyleMenu from '../../../src/modules/text-style/menu/ClearStyleMenu' describe('clear style menu', () => { let editor = createEditor() const startLocation = Editor.start(editor, []) const menu = new ClearStyleMenu() afterEach(() => { editor.select(startLocation) editor.clear() }) it('exec', () => { editor.select(startLocation) editor.insertText('hello') editor.select([]) editor.addMark('bold', true) editor.addMark('italic', true) menu.exec(editor, '') // 清空样式 const marks = Editor.marks(editor) as any expect(marks.bold).toBeUndefined() expect(marks.italic).toBeUndefined() }) }) ================================================ FILE: packages/basic-modules/__tests__/text-style/menu/menus.test.ts ================================================ /** * @description style menus test * @author wangfupeng */ import { Editor, Transforms, Element } from 'slate' import createEditor from '../../../../../tests/utils/create-editor' import BoldMenu from '../../../src/modules/text-style/menu/BoldMenu' import CodeMenu from '../../../src/modules/text-style/menu/CodeMenu' import ItalicMenu from '../../../src/modules/text-style/menu/ItalicMenu' import SubMenu from '../../../src/modules/text-style/menu/SubMenu' import SupMenu from '../../../src/modules/text-style/menu/SupMenu' import ThroughMenu from '../../../src/modules/text-style/menu/ThroughMenu' import UnderlineMenu from '../../../src/modules/text-style/menu/UnderlineMenu' const MENU_INFO_LIST = [ { mark: 'bold', menu: new BoldMenu() }, { mark: 'code', menu: new CodeMenu() }, { mark: 'italic', menu: new ItalicMenu() }, { mark: 'sub', menu: new SubMenu() }, { mark: 'sup', menu: new SupMenu() }, { mark: 'through', menu: new ThroughMenu() }, { mark: 'underline', menu: new UnderlineMenu() }, ] describe('text style menus', () => { let editor = createEditor() const startLocation = Editor.start(editor, []) afterEach(() => { editor.select(startLocation) editor.clear() }) // getValue 已经被 isActive 覆盖 it('is active', () => { MENU_INFO_LIST.forEach(info => { const { mark, menu } = info editor.select(startLocation) editor.clear() editor.insertText('hello') expect(menu.isActive(editor)).toBeFalsy() editor.select([]) editor.addMark(mark, true) expect(menu.isActive(editor)).toBeTruthy() }) }) it('is disable', () => { MENU_INFO_LIST.forEach(info => { const { mark, menu } = info editor.select(startLocation) editor.clear() editor.insertText('hello') expect(menu.isDisabled(editor)).toBeFalsy() // 正常文字,不禁用 editor.insertNode({ type: 'pre', children: [ { type: 'code', children: [{ text: 'var' }], } as Element, ], } as Element) expect(menu.isDisabled(editor)).toBeTruthy() // 选中代码块,禁用各个 menu }) }) it('exec', () => { MENU_INFO_LIST.forEach(info => { const { mark, menu } = info editor.select(startLocation) editor.clear() editor.insertText('hello') editor.select([]) // 增加 mark menu.exec(editor, false) const marks1 = Editor.marks(editor) as any expect(marks1[mark]).toBeTruthy() // 取消 mark editor.select([]) menu.exec(editor, true) const marks2 = Editor.marks(editor) as any expect(marks2[mark]).toBeUndefined() }) }) }) ================================================ FILE: packages/basic-modules/__tests__/text-style/parse-html.test.ts ================================================ /** * @description parse html test * @author wangfupeng */ // import { $ } from 'dom7' // import { parseStyleHtml } from '../../../../packages/basic-modules/src/modules/text-style/parse-style-html' describe('text style - parse style html', () => { it('占位', () => { expect(1 + 1).toBe(2) }) // TODO 执行以下代码会有 Dom7 一个怪异的 bug ,先暂且注释,后面再解决 wangfupeng 2022.01.17 // it('bold', () => { // const $text = $('') // const textNode = { text: 'hello' } // // parse style // const res = parseStyleHtml($text[0], textNode) // expect(res).toEqual({ // text: 'hello', // bold: true, // }) // }) // // italic underline... 等 }) ================================================ FILE: packages/basic-modules/__tests__/text-style/parse-style-html.test.ts ================================================ import { parseStyleHtml } from '../../src/modules/text-style/parse-style-html' import $ from '../../src/utils/dom' import createEditor from '../../../../tests/utils/create-editor' describe('parse style html', () => { const editor = createEditor() it('it should return directly if give node that type is not text', () => { const element = $('') const node = { type: 'paragraph', children: [] } expect(parseStyleHtml(element[0], node, editor)).toEqual(node) }) it('it should do nothing if give not exist element', () => { const element = $('#text') const node = { type: 'paragraph', children: [] } expect(parseStyleHtml(element[0], node, editor)).toEqual(node) }) it('it should set bold property for node if give strong element', () => { const element = $('') const node = { text: 'text' } expect(parseStyleHtml(element[0], node, editor)).toEqual({ ...node, bold: true }) }) it('it should set bold property for node if give b element', () => { const element = $('') const node = { text: 'text' } expect(parseStyleHtml(element[0], node, editor)).toEqual({ ...node, bold: true }) }) it('it should set italic property for node if give i element', () => { const element = $('') const node = { text: 'text' } expect(parseStyleHtml(element[0], node, editor)).toEqual({ ...node, italic: true }) }) it('it should set italic property for node if give em element', () => { const element = $('') const node = { text: 'text' } expect(parseStyleHtml(element[0], node, editor)).toEqual({ ...node, italic: true }) }) it('it should set underline property for node if give u element', () => { const element = $('') const node = { text: 'text' } expect(parseStyleHtml(element[0], node, editor)).toEqual({ ...node, underline: true }) }) it('it should set through property for node if give s element', () => { const element = $('')
const node = { text: 'text' }
expect(parseStyleHtml(element[0], node, editor)).toEqual({ ...node, code: true })
})
})
================================================
FILE: packages/basic-modules/__tests__/text-style/text-style.test.tsx
================================================
/**
* @description text style test
* @author wangfupeng
*/
import { jsx } from 'snabbdom'
import { renderStyle } from '../../src/modules/text-style/render-style'
import { StyledText } from '../../src/modules/text-style/custom-types'
describe('text style - render text style', () => {
it('render text style', () => {
const vnode = hello
let newVnode
const textNode: StyledText = { text: '' }
textNode.bold = true
// @ts-ignore 忽略 vnode 格式
newVnode = renderStyle(textNode, vnode)
expect(newVnode.sel).toBe('strong')
textNode.code = true
// @ts-ignore 忽略 vnode 格式
newVnode = renderStyle(textNode, vnode)
expect(newVnode.sel).toBe('code')
textNode.italic = true
// @ts-ignore 忽略 vnode 格式
newVnode = renderStyle(textNode, vnode)
expect(newVnode.sel).toBe('em')
textNode.underline = true
// @ts-ignore 忽略 vnode 格式
newVnode = renderStyle(textNode, vnode)
expect(newVnode.sel).toBe('u')
textNode.through = true
// @ts-ignore 忽略 vnode 格式
newVnode = renderStyle(textNode, vnode)
expect(newVnode.sel).toBe('s')
textNode.sub = true
// @ts-ignore 忽略 vnode 格式
newVnode = renderStyle(textNode, vnode)
expect(newVnode.sel).toBe('sub')
textNode.sup = true
// @ts-ignore 忽略 vnode 格式
newVnode = renderStyle(textNode, vnode)
expect(newVnode.sel).toBe('sup')
})
})
================================================
FILE: packages/basic-modules/__tests__/text-style/text-to-html.test.ts
================================================
/**
* @description text to html test
* @author wangfupeng
*/
import { styleToHtml } from '../../src/modules/text-style/style-to-html'
describe('text style - text to html', () => {
it('text to html', () => {
const textNode = {
text: '',
bold: true,
italic: true,
underline: true,
code: true,
through: true,
sub: true,
sup: true,
}
const html1 = styleToHtml(textNode, 'hello')
expect(html1).toBe(
'helloworld${childrenHtml}` } export const quoteToHtmlConf = { type: 'blockquote', elemToHtml: quoteToHtml, } ================================================ FILE: packages/basic-modules/src/modules/blockquote/index.ts ================================================ /** * @description blockquote entry * @author wangfupeng */ import { IModuleConf } from '@wangeditor/core' import { renderBlockQuoteConf } from './render-elem' import { quoteToHtmlConf } from './elem-to-html' import { parseHtmlConf } from './parse-elem-html' import { blockquoteMenuConf } from './menu/index' import withBlockquote from './plugin' const blockquote: Partial
{children}return vnode } export const renderBlockQuoteConf = { type: 'blockquote', renderElem: renderBlockQuote, } ================================================ FILE: packages/basic-modules/src/modules/code-block/custom-types.ts ================================================ /** * @description 自定义 element * @author wangfupeng */ //【注意】需要把自定义的 Element 引入到最外层的 custom-types.d.ts type PureText = { text: string } export type PreElement = { type: 'pre' children: CodeElement[] } export type CodeElement = { type: 'code' language: string children: PureText[] } ================================================ FILE: packages/basic-modules/src/modules/code-block/elem-to-html.ts ================================================ /** * @description to html * @author wangfupeng */ import { Element } from 'slate' function codeToHtml(elem: Element, childrenHtml: string): string { // 代码高亮 `class="language-xxx"` 在 code-highlight 中实现 return `
${childrenHtml}`
}
export const codeToHtmlConf = {
type: 'code',
elemToHtml: codeToHtml,
}
function preToHtml(elem: Element, childrenHtml: string): string {
return `${childrenHtml}`
}
export const preToHtmlConf = {
type: 'pre',
elemToHtml: preToHtml,
}
================================================
FILE: packages/basic-modules/src/modules/code-block/index.ts
================================================
/**
* @description code block module
* @author wangfupeng
*/
import { IModuleConf } from '@wangeditor/core'
import { codeBlockMenuConf } from './menu/index'
import withCodeBlock from './plugin'
import { renderPreConf, renderCodeConf } from './render-elem'
import { preParseHtmlConf } from './pre-parse-html'
import { parseCodeHtmlConf, parsePreHtmlConf } from './parse-elem-html'
import { codeToHtmlConf, preToHtmlConf } from './elem-to-html'
const codeBlockModule: Partial 下的
parseElemHtml: parseCodeHtml,
}
function parsePreHtml(elem: DOMElement, children: Descendant[], editor: IDomEditor): PreElement {
const $elem = $(elem)
children = children.filter(child => DomEditor.getNodeType(child) === 'code')
if (children.length === 0) {
children = [{ type: 'code', language: '', children: [{ text: $elem[0].textContent || '' }] }]
}
return {
type: 'pre',
// @ts-ignore
children: children.filter(child => DomEditor.getNodeType(child) === 'code'),
}
}
export const parsePreHtmlConf = {
selector: 'pre:not([data-w-e-type])', // data-w-e-type 属性,留给自定义元素,保证扩展性
parseElemHtml: parsePreHtml,
}
================================================
FILE: packages/basic-modules/src/modules/code-block/plugin.ts
================================================
/**
* @description editor 插件,重写 editor API
* @author wangfupeng
*/
import { Editor, Transforms, Node as SlateNode, Element as SlateElement } from 'slate'
import { IDomEditor, DomEditor } from '@wangeditor/core'
function getLastTextLineBeforeSelection(codeNode: SlateNode, editor: IDomEditor): string {
const selection = editor.selection
if (selection == null) return ''
const codeText = SlateNode.string(codeNode)
const anchorOffset = selection.anchor.offset
const textBeforeAnchor = codeText.slice(0, anchorOffset) // 选区前的 text
const arr = textBeforeAnchor.split('\n') // 选区前的 text ,按换行拆分
const length = arr.length
if (length === 0) return ''
return arr[length - 1]
}
function withCodeBlock(editor: T): T {
const { insertBreak, normalizeNode, insertData, insertNode } = editor
const newEditor = editor
// 重写换行操作
newEditor.insertBreak = () => {
const codeNode = DomEditor.getSelectedNodeByType(newEditor, 'code')
if (codeNode == null) {
insertBreak() // 执行默认的换行
return
}
// 回车时,根据当前行的空格,自动插入空格
const lastLineBeforeSelection = getLastTextLineBeforeSelection(codeNode, newEditor)
if (lastLineBeforeSelection) {
const arr = lastLineBeforeSelection.match(/^\s+/) // 行开始的空格
if (arr != null && arr[0] != null) {
const spaces = arr[0]
newEditor.insertText(`\n${spaces}`) // 换行后插入空格
return
}
}
// 普通换行
newEditor.insertText('\n')
}
// 重写 normalizeNode
newEditor.normalizeNode = ([node, path]) => {
const type = DomEditor.getNodeType(node)
// -------------- code node 不能是顶层,否则替换为 p --------------
if (type === 'code' && path.length <= 1) {
Transforms.setNodes(newEditor, { type: 'paragraph' }, { at: path })
}
if (type === 'pre') {
// -------------- pre 是 editor 最后一个节点,需要后面插入 p --------------
const isLast = DomEditor.isLastNode(newEditor, node)
if (isLast) {
Transforms.insertNodes(newEditor, DomEditor.genEmptyParagraph(), { at: [path[0] + 1] })
}
// -------------- pre 下面必须是 code --------------
if (DomEditor.getNodeType((node as SlateElement).children[0]) !== 'code') {
Transforms.unwrapNodes(newEditor)
Transforms.setNodes(newEditor, { type: 'paragraph' }, { mode: 'highest' })
}
}
// 执行默认行为
return normalizeNode([node, path])
}
// 重写 insertData - 粘贴文本
newEditor.insertData = (data: DataTransfer) => {
const codeNode = DomEditor.getSelectedNodeByType(newEditor, 'code')
if (codeNode == null) {
insertData(data) // 执行默认的 insertData
return
}
// 获取文本,并插入到代码块
const text = data.getData('text/plain')
Editor.insertText(newEditor, text)
}
// 返回 editor ,重要!
return newEditor
}
export default withCodeBlock
================================================
FILE: packages/basic-modules/src/modules/code-block/pre-parse-html.ts
================================================
/**
* @description pre parse html
* @author wangfupeng
*/
import $, { DOMElement } from '../../utils/dom'
import { getTagName } from '../../utils/dom'
/**
* pre-prase ,去掉其中的 (兼容 V4)
* @param codeElem codeElem
*/
function preParse(codeElem: DOMElement): DOMElement {
const $code = $(codeElem)
const tagName = getTagName($code)
if (tagName !== 'code') return codeElem
const $xmp = $code.find('xmp')
if ($xmp.length === 0) return codeElem // 不是 V4 格式
const codeText = $xmp.text()
$xmp.remove()
$code.text(codeText)
return $code[0]
}
export const preParseHtmlConf = {
selector: 'pre>code', // 匹配 下的
preParseHtml: preParse,
}
================================================
FILE: packages/basic-modules/src/modules/code-block/render-elem.tsx
================================================
/**
* @description render elem
* @author wangfupeng
*/
import { Element as SlateElement } from 'slate'
import { jsx, VNode } from 'snabbdom'
import { IDomEditor } from '@wangeditor/core'
function renderPre(elemNode: SlateElement, children: VNode[] | null, editor: IDomEditor): VNode {
const vnode = {children}
return vnode
}
function renderCode(elemNode: SlateElement, children: VNode[] | null, editor: IDomEditor): VNode {
// 和 basic/simple-style module 的“行内代码”并不冲突。一个是根据 mark 渲染,一个是根据 node.type 渲染
const vnode = {children}
return vnode
}
export const renderPreConf = {
type: 'pre',
renderElem: renderPre,
}
export const renderCodeConf = {
type: 'code',
renderElem: renderCode,
}
================================================
FILE: packages/basic-modules/src/modules/color/custom-types.ts
================================================
/**
* @description 自定义 element
* @author wangfupeng
*/
//【注意】需要把自定义的 Text 引入到最外层的 custom-types.d.ts
export type ColorText = {
text: string
color?: string
bgColor?: string
}
================================================
FILE: packages/basic-modules/src/modules/color/index.ts
================================================
/**
* @description color bgColor
* @author wangfupeng
*/
import { IModuleConf } from '@wangeditor/core'
import { renderStyle } from './render-style'
import { styleToHtml } from './style-to-html'
import { preParseHtmlConf } from './pre-parse-html'
import { parseStyleHtml } from './parse-style-html'
import { colorMenuConf, bgColorMenuConf } from './menu/index'
const color: Partial = {
renderStyle,
styleToHtml,
preParseHtml: [preParseHtmlConf],
parseStyleHtml,
menus: [colorMenuConf, bgColorMenuConf],
}
export default color
================================================
FILE: packages/basic-modules/src/modules/color/menu/BaseMenu.ts
================================================
/**
* @description color base menu
* @author wangfupeng
*/
import { Editor, Range } from 'slate'
import { IDropPanelMenu, IDomEditor, DomEditor, t } from '@wangeditor/core'
import $, { Dom7Array, DOMElement } from '../../../utils/dom'
import { CLEAN_SVG } from '../../../constants/icon-svg'
abstract class BaseMenu implements IDropPanelMenu {
abstract readonly title: string
abstract readonly iconSvg: string
readonly tag = 'button'
readonly showDropPanel = true // 点击 button 时显示 dropPanel
protected abstract readonly mark: string
private $content: Dom7Array | null = null
exec(editor: IDomEditor, value: string | boolean) {
// 点击菜单时,弹出 droPanel 之前,不需要执行其他代码
// 此处空着即可
}
getValue(editor: IDomEditor): string | boolean {
const mark = this.mark
const curMarks = Editor.marks(editor)
// @ts-ignore
if (curMarks && curMarks[mark]) return curMarks[mark]
return ''
}
isActive(editor: IDomEditor): boolean {
const color = this.getValue(editor)
return !!color
}
isDisabled(editor: IDomEditor): boolean {
if (editor.selection == null) return true
const [match] = Editor.nodes(editor, {
match: n => {
const type = DomEditor.getNodeType(n)
if (type === 'pre') return true // 代码块
if (Editor.isVoid(editor, n)) return true // void node
return false
},
universal: true,
})
// 命中,则禁用
if (match) return true
return false
}
getPanelContentElem(editor: IDomEditor): DOMElement {
const mark = this.mark
if (this.$content == null) {
// 第一次渲染
const $content = $('
')
// 绑定事件(只在第一次绑定,不要重复绑定)
$content.on('click', 'li', (e: Event) => {
const { target } = e
if (target == null) return
e.preventDefault()
const { selection } = editor
if (selection == null) return
const $li = $(target)
const val = $li.attr('data-value')
// 修改文本样式
if (val === '0') {
Editor.removeMark(editor, mark)
} else {
Editor.addMark(editor, mark, val)
}
})
this.$content = $content
}
const $content = this.$content
if ($content == null) return document.createElement('ul')
$content.empty() // 清空之后再重置内容
// 当前选中文本的颜色之
const selectedColor = this.getValue(editor)
// 获取菜单配置
const colorConf = editor.getMenuConfig(mark)
const { colors = [] } = colorConf
// 根据菜单配置生成 panel content
colors.forEach((color: string) => {
const $block = $(``)
$block.css('background-color', color)
const $li = $(``)
if (selectedColor === color) {
$li.addClass('active')
}
$li.append($block)
$content.append($li)
})
// 清除颜色
let clearText = ''
if (mark === 'color') clearText = t('color.default')
if (mark === 'bgColor') clearText = t('color.clear')
const $clearLi = $(`
${CLEAN_SVG}
${clearText}
`)
$content.prepend($clearLi)
return $content[0]
}
}
export default BaseMenu
================================================
FILE: packages/basic-modules/src/modules/color/menu/BgColorMenu.ts
================================================
/**
* @description bg color menu
* @author wangfupeng
*/
import { t } from '@wangeditor/core'
import BaseMenu from './BaseMenu'
import { BG_COLOR_SVG } from '../../../constants/icon-svg'
class BgColorMenu extends BaseMenu {
readonly title = t('color.bgColor')
readonly iconSvg = BG_COLOR_SVG
readonly mark = 'bgColor'
}
export default BgColorMenu
================================================
FILE: packages/basic-modules/src/modules/color/menu/ColorMenu.ts
================================================
/**
* @description color menu
* @author wangfupeng
*/
import { t } from '@wangeditor/core'
import BaseMenu from './BaseMenu'
import { FONT_COLOR_SVG } from '../../../constants/icon-svg'
class ColorMenu extends BaseMenu {
readonly title = t('color.color')
readonly iconSvg = FONT_COLOR_SVG
readonly mark = 'color'
}
export default ColorMenu
================================================
FILE: packages/basic-modules/src/modules/color/menu/config.ts
================================================
/**
* @description menu config
* @author wangfupeng
*/
const COLORS = [
'rgb(0, 0, 0)',
'rgb(38, 38, 38)',
'rgb(89, 89, 89)',
'rgb(140, 140, 140)',
'rgb(191, 191, 191)',
'rgb(217, 217, 217)',
'rgb(233, 233, 233)',
'rgb(245, 245, 245)',
'rgb(250, 250, 250)',
'rgb(255, 255, 255)', // 10
'rgb(225, 60, 57)',
'rgb(231, 95, 51)',
'rgb(235, 144, 58)',
'rgb(245, 219, 77)',
'rgb(114, 192, 64)',
'rgb(89, 191, 192)',
'rgb(66, 144, 247)',
'rgb(54, 88, 226)',
'rgb(106, 57, 201)',
'rgb(216, 68, 147)', // 10
'rgb(251, 233, 230)',
'rgb(252, 237, 225)',
'rgb(252, 239, 212)',
'rgb(252, 251, 207)',
'rgb(231, 246, 213)',
'rgb(218, 244, 240)',
'rgb(217, 237, 250)',
'rgb(224, 232, 250)',
'rgb(237, 225, 248)',
'rgb(246, 226, 234)', // 10
'rgb(255, 163, 158)',
'rgb(255, 187, 150)',
'rgb(255, 213, 145)',
'rgb(255, 251, 143)',
'rgb(183, 235, 143)',
'rgb(135, 232, 222)',
'rgb(145, 213, 255)',
'rgb(173, 198, 255)',
'rgb(211, 173, 247)',
'rgb(255, 173, 210)', // 10
'rgb(255, 77, 79)',
'rgb(255, 122, 69)',
'rgb(255, 169, 64)',
'rgb(255, 236, 61)',
'rgb(115, 209, 61)',
'rgb(54, 207, 201)',
'rgb(64, 169, 255)',
'rgb(89, 126, 247)',
'rgb(146, 84, 222)',
'rgb(247, 89, 171)', // 10
'rgb(207, 19, 34)',
'rgb(212, 56, 13)',
'rgb(212, 107, 8)',
'rgb(212, 177, 6)',
'rgb(56, 158, 13)',
'rgb(8, 151, 156)',
'rgb(9, 109, 217)',
'rgb(29, 57, 196)',
'rgb(83, 29, 171)',
'rgb(196, 29, 127)', // 10
'rgb(130, 0, 20)',
'rgb(135, 20, 0)',
'rgb(135, 56, 0)',
'rgb(97, 71, 0)',
'rgb(19, 82, 0)',
'rgb(0, 71, 79)',
'rgb(0, 58, 140)',
'rgb(6, 17, 120)',
'rgb(34, 7, 94)',
'rgb(120, 6, 80)', // 10
]
export function genColors() {
return COLORS
}
export function genBgColors() {
return COLORS
}
================================================
FILE: packages/basic-modules/src/modules/color/menu/index.ts
================================================
/**
* @description menu entry
* @author wangfupeng
*/
import ColorMenu from './ColorMenu'
import BgColorMenu from './BgColorMenu'
import { genColors, genBgColors } from './config'
export const colorMenuConf = {
key: 'color',
factory() {
return new ColorMenu()
},
// 默认的菜单菜单配置,将存储在 editorConfig.MENU_CONF[key] 中
// 创建编辑器时,可通过 editorConfig.MENU_CONF[key] = {...} 来修改
config: {
colors: genColors(),
},
}
export const bgColorMenuConf = {
key: 'bgColor',
factory() {
return new BgColorMenu()
},
config: {
colors: genBgColors(),
},
}
================================================
FILE: packages/basic-modules/src/modules/color/parse-style-html.ts
================================================
/**
* @description parse style html
* @author wangfupeng
*/
import { Descendant, Text } from 'slate'
import { IDomEditor } from '@wangeditor/core'
import { ColorText } from './custom-types'
import $, { DOMElement, getStyleValue } from '../../utils/dom'
export function parseStyleHtml(text: DOMElement, node: Descendant, editor: IDomEditor): Descendant {
const $text = $(text)
if (!Text.isText(node)) return node
const textNode = node as ColorText
const color = getStyleValue($text, 'color')
if (color) {
textNode.color = color
}
let bgColor = getStyleValue($text, 'background-color')
if (!bgColor) bgColor = getStyleValue($text, 'background') // word 背景色
if (bgColor) {
textNode.bgColor = bgColor
}
return textNode
}
================================================
FILE: packages/basic-modules/src/modules/color/pre-parse-html.ts
================================================
/**
* @description pre-parse html
* @author wangfupeng
*/
import $, { DOMElement, getTagName } from '../../utils/dom'
/**
* pre-prase font ,兼容 V4
* @param fontElem fontElem
*/
function preParse(fontElem: DOMElement): DOMElement {
const $font = $(fontElem)
const tagName = getTagName($font)
if (tagName !== 'font') return fontElem
// 处理 color (V4 使用 xx 格式)
const color = $font.attr('color') || ''
if (color) {
$font.removeAttr('color')
$font.css('color', color)
}
return $font[0]
}
export const preParseHtmlConf = {
selector: 'font',
preParseHtml: preParse,
}
================================================
FILE: packages/basic-modules/src/modules/color/render-style.tsx
================================================
/**
* @description render color style
* @author wangfupeng
*/
import { Descendant } from 'slate'
import { jsx, VNode } from 'snabbdom'
import { addVnodeStyle } from '../../utils/vdom'
import { ColorText } from './custom-types'
/**
* 添加样式
* @param node text node
* @param vnode vnode
* @returns vnode
*/
export function renderStyle(node: Descendant, vnode: VNode): VNode {
const { color, bgColor } = node as ColorText
let styleVnode: VNode = vnode
if (color) {
addVnodeStyle(styleVnode, { color })
}
if (bgColor) {
addVnodeStyle(styleVnode, { backgroundColor: bgColor })
}
return styleVnode
}
================================================
FILE: packages/basic-modules/src/modules/color/style-to-html.ts
================================================
/**
* @description textStyle to html
* @author wangfupeng
*/
import { Text, Descendant } from 'slate'
import $, { getOuterHTML, getTagName, isPlainText } from '../../utils/dom'
import { ColorText } from './custom-types'
/**
* style to html
* @param textNode slate text node
* @param textHtml text html
* @returns styled html
*/
export function styleToHtml(textNode: Descendant, textHtml: string): string {
if (!Text.isText(textNode)) return textHtml
const { color, bgColor } = textNode as ColorText
if (!color && !bgColor) return textHtml
let $text
if (isPlainText(textHtml)) {
// textHtml 是纯文本,不是 html tag
$text = $(`${textHtml}`)
} else {
// textHtml 是 html tag
$text = $(textHtml)
const tagName = getTagName($text)
if (tagName !== 'span') {
// 如果不是 span ,则包裹一层,接下来要设置 css
$text = $(`${textHtml}`)
}
}
// 设置样式
if (color) $text.css('color', color)
if (bgColor) $text.css('background-color', bgColor)
// 输出 html
return getOuterHTML($text)
}
================================================
FILE: packages/basic-modules/src/modules/common/index.ts
================================================
/**
* @description common module
* @author wangfupeng
*/
import { IModuleConf } from '@wangeditor/core'
import { enterMenuConf } from './menu/index'
const commonModule: Partial = {
menus: [enterMenuConf],
}
export default commonModule
================================================
FILE: packages/basic-modules/src/modules/common/menu/EnterMenu.ts
================================================
/**
* @description enter menu
* @author wangfupeng
*/
import { Range, Transforms, Editor } from 'slate'
import { IButtonMenu, IDomEditor, t } from '@wangeditor/core'
import { ENTER_SVG } from '../../../constants/icon-svg'
class EnterMenu implements IButtonMenu {
title = t('common.enter')
iconSvg = ENTER_SVG
tag = 'button'
getValue(editor: IDomEditor): string | boolean {
return ''
}
isActive(editor: IDomEditor): boolean {
return false
}
isDisabled(editor: IDomEditor): boolean {
const { selection } = editor
if (selection == null) return true
if (Range.isExpanded(selection)) return true
return false
}
exec(editor: IDomEditor, value: string | boolean) {
const { selection } = editor
if (selection == null) return
const { anchor } = selection
const { path } = anchor
// 在当前位置插入空行,当前元素下移
const newElem = { type: 'paragraph', children: [{ text: '' }] }
const newPath = [path[0]]
Transforms.insertNodes(editor, newElem, { at: newPath })
editor.select(Editor.start(editor, newPath))
}
}
export default EnterMenu
================================================
FILE: packages/basic-modules/src/modules/common/menu/index.ts
================================================
/**
* @description common menu config
* @author wangfupeng
*/
import EnterMenu from './EnterMenu'
export const enterMenuConf = {
key: 'enter',
factory() {
return new EnterMenu()
},
}
================================================
FILE: packages/basic-modules/src/modules/divider/README.md
================================================
# 分割线
================================================
FILE: packages/basic-modules/src/modules/divider/custom-types.ts
================================================
/**
* @description divider element
* @author wangfupeng
*/
//【注意】需要把自定义的 Element 引入到最外层的 custom-types.d.ts
type EmptyText = {
text: ''
}
export type DividerElement = {
type: 'divider'
children: EmptyText[]
}
================================================
FILE: packages/basic-modules/src/modules/divider/elem-to-html.ts
================================================
/**
* @description to html
* @author wangfupeng
*/
import { Element } from 'slate'
function dividerToHtml(elem: Element, childrenHtml: string): string {
return `
`
}
export const dividerToHtmlConf = {
type: 'divider',
elemToHtml: dividerToHtml,
}
================================================
FILE: packages/basic-modules/src/modules/divider/index.ts
================================================
/**
* @description divider module
* @author wangfupeng
*/
import { IModuleConf } from '@wangeditor/core'
import withDivider from './plugin'
import { renderDividerConf } from './render-elem'
import { dividerToHtmlConf } from './elem-to-html'
import { parseHtmlConf } from './parse-elem-html'
import { insertDividerMenuConf } from './menu/index'
const image: Partial = {
renderElems: [renderDividerConf],
elemsToHtml: [dividerToHtmlConf],
parseElemsHtml: [parseHtmlConf],
menus: [insertDividerMenuConf],
editorPlugin: withDivider,
}
export default image
================================================
FILE: packages/basic-modules/src/modules/divider/menu/DeleteDividerMenu.ts.bak
================================================
/**
* @description delete divider menu
* @author wangfupeng
*/
import { Transforms } from 'slate'
import { IButtonMenu, IDomEditor, DomEditor, t } from '@wangeditor/core'
import { TRASH_SVG } from '../../../constants/icon-svg'
class DeleteDividerMenu implements IButtonMenu {
readonly title = t('common.delete')
readonly iconSvg = TRASH_SVG
readonly tag = 'button'
getValue(editor: IDomEditor): string | boolean {
// 无需获取 val
return ''
}
isActive(editor: IDomEditor): boolean {
// 无需 active
return false
}
isDisabled(editor: IDomEditor): boolean {
if (editor.selection == null) return true
const dividerNode = DomEditor.getSelectedNodeByType(editor, 'divider')
if (dividerNode == null) {
// 选区未处于 divider node ,则禁用
return true
}
return false
}
exec(editor: IDomEditor, value: string | boolean) {
if (this.isDisabled(editor)) return
// 删除
Transforms.removeNodes(editor, {
match: n => DomEditor.checkNodeType(n, 'divider'),
})
}
}
export default DeleteDividerMenu
================================================
FILE: packages/basic-modules/src/modules/divider/menu/InsertDividerMenu.ts
================================================
/**
* @description insert divider menu
* @author wangfupeng
*/
import { Transforms } from 'slate'
import { IButtonMenu, IDomEditor, DomEditor, t } from '@wangeditor/core'
import { DIVIDER_SVG } from '../../../constants/icon-svg'
import { DividerElement } from '../custom-types'
class InsertDividerMenu implements IButtonMenu {
readonly title = t('divider.title')
readonly iconSvg = DIVIDER_SVG
readonly tag = 'button'
getValue(editor: IDomEditor): string | boolean {
return ''
}
isActive(editor: IDomEditor): boolean {
// 不需要 active
return false
}
isDisabled(editor: IDomEditor): boolean {
const { selection } = editor
if (selection == null) return true
const selectedElems = DomEditor.getSelectedElems(editor)
const hasVoidOrTableOrPre = selectedElems.some(elem => {
if (editor.isVoid(elem)) return true
const type = DomEditor.getNodeType(elem)
if (type === 'table') return true
if (type === 'pre') return true
})
if (hasVoidOrTableOrPre) return true // 匹配,则 disable
return false
}
exec(editor: IDomEditor, value: string | boolean): void {
const node: DividerElement = {
type: 'divider',
children: [{ text: '' }], // 【注意】void node 需要一个空 text 作为 children
}
Transforms.insertNodes(editor, node, { mode: 'highest' })
}
}
export default InsertDividerMenu
================================================
FILE: packages/basic-modules/src/modules/divider/menu/index.ts
================================================
/**
* @description divider menu
* @author wangfupeng
*/
import InsertDividerMenu from './InsertDividerMenu'
// import DeleteDividerMenu from './DeleteDividerMenu.ts'
export const insertDividerMenuConf = {
key: 'divider',
factory() {
return new InsertDividerMenu()
},
}
// export const deleteDividerMenuConf = {
// key: 'deleteDivider',
// factory() {
// return new DeleteDividerMenu()
// },
// }
// divider 可用键盘删除了,所以注释掉该菜单 wangfupeng 22.02.23
================================================
FILE: packages/basic-modules/src/modules/divider/parse-elem-html.ts
================================================
/**
* @description parse html
* @author wangfupeng
*/
import { Descendant } from 'slate'
import $, { DOMElement } from '../../utils/dom'
import { IDomEditor } from '@wangeditor/core'
import { DividerElement } from './custom-types'
function parseHtml(elem: DOMElement, children: Descendant[], editor: IDomEditor): DividerElement {
return {
type: 'divider',
children: [{ text: '' }], // void node 有一个空白 text
}
}
export const parseHtmlConf = {
selector: 'hr:not([data-w-e-type])', // data-w-e-type 属性,留给自定义元素,保证扩展性
parseElemHtml: parseHtml,
}
================================================
FILE: packages/basic-modules/src/modules/divider/plugin.ts
================================================
/**
* @description editor 插件,重写 editor API
* @author wangfupeng
*/
import { Transforms, Element } from 'slate'
import { IDomEditor, DomEditor } from '@wangeditor/core'
function withDivider(editor: T): T {
const { isVoid, normalizeNode } = editor
const newEditor = editor
// 重写 isVoid
newEditor.isVoid = elem => {
const { type } = elem
if (type === 'divider') {
return true
}
return isVoid(elem)
}
// 重新 normalize
newEditor.normalizeNode = ([node, path]) => {
const type = DomEditor.getNodeType(node)
if (type !== 'divider') {
// 未命中 divider ,执行默认的 normalizeNode
return normalizeNode([node, path])
}
// -------------- divider 是 editor 最后一个节点,需要后面插入 p --------------
const isLast = DomEditor.isLastNode(newEditor, node)
if (isLast) {
Transforms.insertNodes(newEditor, DomEditor.genEmptyParagraph(), { at: [path[0] + 1] })
}
}
// 返回 editor ,重要!
return newEditor
}
export default withDivider
================================================
FILE: packages/basic-modules/src/modules/divider/render-elem.tsx
================================================
/**
* @description render divider elem
* @author wangfupeng
*/
import { Element as SlateElement } from 'slate'
import { h, VNode } from 'snabbdom'
import { IDomEditor, DomEditor } from '@wangeditor/core'
function renderDivider(
elemNode: SlateElement,
children: VNode[] | null,
editor: IDomEditor
): VNode {
const renderStyle: any = {}
// 是否选中
const selected = DomEditor.isNodeSelected(editor, elemNode)
const vnode = h(
'div',
{
props: {
contentEditable: false,
className: 'w-e-textarea-divider',
},
dataset: {
selected: selected ? 'true' : '',
},
style: renderStyle,
on: {
mousedown: event => event.preventDefault(),
},
},
[h('hr')]
)
// 【注意】void node 中,renderElem 不用处理 children 。core 会统一处理。
return vnode
}
const renderDividerConf = {
type: 'divider', // 和 elemNode.type 一致
renderElem: renderDivider,
}
export { renderDividerConf }
================================================
FILE: packages/basic-modules/src/modules/emotion/index.ts
================================================
/**
* @description emotion entry
* @author wangfupeng
*/
import { IModuleConf } from '@wangeditor/core'
import { emotionMenuConf } from './menu/index'
const emotion: Partial = {
menus: [emotionMenuConf],
}
export default emotion
================================================
FILE: packages/basic-modules/src/modules/emotion/menu/EmotionMenu.ts
================================================
/**
* @description emotion menu
* @author wangfupeng
*/
import { Editor } from 'slate'
import { IDropPanelMenu, IDomEditor, DomEditor, t } from '@wangeditor/core'
import $, { Dom7Array, DOMElement } from '../../../utils/dom'
import { EMOTION_SVG } from '../../../constants/icon-svg'
class EmotionMenu implements IDropPanelMenu {
readonly title = t('emotion.title')
readonly iconSvg = EMOTION_SVG
readonly tag = 'button'
readonly showDropPanel = true // 点击 button 时显示 dropPanel
private $content: Dom7Array | null = null
exec(editor: IDomEditor, value: string | boolean) {
// 点击菜单时,弹出 droPanel 之前,不需要执行其他代码
// 此处空着即可
}
getValue(editor: IDomEditor): string | boolean {
// 不需要 getValue
return ''
}
isActive(editor: IDomEditor): boolean {
// 不需要 active
return false
}
isDisabled(editor: IDomEditor): boolean {
if (editor.selection == null) return true
const [match] = Editor.nodes(editor, {
match: n => {
const type = DomEditor.getNodeType(n)
if (type === 'pre') return true // 代码块
if (Editor.isVoid(editor, n)) return true // void node
return false
},
universal: true,
})
if (match) return true
return false
}
getPanelContentElem(editor: IDomEditor): DOMElement {
if (this.$content == null) {
// 第一次渲染
const $content = $('
')
// 绑定事件(仅第一次绑定,不可重复绑定)
$content.on('click', 'li', (e: Event) => {
const { target } = e
if (target == null) return
e.preventDefault()
const $li = $(target)
const emotionStr = $li.text()
editor.insertText(emotionStr)
})
this.$content = $content
}
const $content = this.$content
if ($content == null) return document.createElement('ul')
$content.empty() // 清空之后再重置内容
// 获取菜单配置
const colorConf = editor.getMenuConfig('emotion')
const { emotions = [] } = colorConf
// 根据菜单配置生成 panel content
emotions.forEach((emotion: string) => {
const $li = $(`${emotion} `)
$content.append($li)
})
return $content[0]
}
}
export default EmotionMenu
================================================
FILE: packages/basic-modules/src/modules/emotion/menu/config.ts
================================================
/**
* @description menu config
* @author wangfupeng
*/
export function genConfig() {
const emotions =
'😀 😃 😄 😁 😆 😅 😂 🤣 😊 😇 🙂 🙃 😉 😌 😍 😘 😗 😙 😚 😋 😛 😝 😜 🤓 😎 😏 😒 😞 😔 😟 😕 🙁 😣 😖 😫 😩 😢 😭 😤 😠 😡 😳 😱 😨 🤗 🤔 😶 😑 😬 🙄 😯 😴 😷 🤑 😈 🤡 💩 👻 💀 👀 👣 👐 🙌 👏 🤝 👍 👎 👊 ✊ 🤛 🤜 🤞 ✌️ 🤘 👌 👈 👉 👆 👇 ☝️ ✋ 🤚 🖐 🖖 👋 🤙 💪 🖕 ✍️ 🙏'
return emotions.split(' ')
}
================================================
FILE: packages/basic-modules/src/modules/emotion/menu/index.ts
================================================
/**
* @description emotion menu
* @author wangfupeng
*/
import EmotionMenu from './EmotionMenu'
import { genConfig } from './config'
export const emotionMenuConf = {
key: 'emotion',
factory() {
return new EmotionMenu()
},
// 默认的菜单菜单配置,将存储在 editorConfig.MENU_CONF[key] 中
// 创建编辑器时,可通过 editorConfig.MENU_CONF[key] = {...} 来修改
config: {
emotions: genConfig(),
},
}
================================================
FILE: packages/basic-modules/src/modules/font-size-family/custom-types.ts
================================================
/**
* @description 自定义 element
* @author wangfupeng
*/
//【注意】需要把自定义的 Text 引入到最外层的 custom-types.d.ts
export type FontSizeAndFamilyText = {
text: string
fontSize?: string
fontFamily?: string
}
================================================
FILE: packages/basic-modules/src/modules/font-size-family/index.ts
================================================
/**
* @description font-size font-family
* @author wangfupeng
*/
import { IModuleConf } from '@wangeditor/core'
import { renderStyle } from './render-style'
import { styleToHtml } from './style-to-html'
import { preParseHtmlConf } from './pre-parse-html'
import { parseStyleHtml } from './parse-style-html'
import { fontSizeMenuConf, fontFamilyMenuConf } from './menu/index'
const fontSizeAndFamily: Partial = {
renderStyle,
styleToHtml,
preParseHtml: [preParseHtmlConf],
parseStyleHtml,
menus: [fontSizeMenuConf, fontFamilyMenuConf],
}
export default fontSizeAndFamily
================================================
FILE: packages/basic-modules/src/modules/font-size-family/menu/BaseMenu.ts
================================================
/**
* @description header menu
* @author wangfupeng
*/
import { Editor } from 'slate'
import { ISelectMenu, IDomEditor, DomEditor, IOption } from '@wangeditor/core'
abstract class BaseMenu implements ISelectMenu {
abstract readonly title: string
abstract readonly iconSvg: string
abstract readonly mark: string // 'fontSize'/'fontFamily'
readonly tag = 'select'
readonly width = 80
abstract getOptions(editor: IDomEditor): IOption[]
isActive(editor: IDomEditor): boolean {
// select menu 会显示 selected value ,用不到 active
return false
}
getValue(editor: IDomEditor): string | boolean {
const mark = this.mark
const curMarks = Editor.marks(editor)
// @ts-ignore
if (curMarks && curMarks[mark]) return curMarks[mark]
return ''
}
isDisabled(editor: IDomEditor): boolean {
if (editor.selection == null) return true
const mark = this.mark
const [match] = Editor.nodes(editor, {
match: n => {
const type = DomEditor.getNodeType(n)
if (type === 'pre') return true // 代码块
if (Editor.isVoid(editor, n)) return true // void node
return false
},
universal: true,
})
// 匹配到,则禁用
if (match) return true
return false
}
exec(editor: IDomEditor, value: string | boolean) {
const mark = this.mark
if (value) {
editor.addMark(mark, value)
} else {
editor.removeMark(mark)
}
}
}
export default BaseMenu
================================================
FILE: packages/basic-modules/src/modules/font-size-family/menu/FontFamilyMenu.ts
================================================
/**
* @description font-family menu
* @author wangfupeng
*/
import { IDomEditor, IOption, t } from '@wangeditor/core'
import BaseMenu from './BaseMenu'
import { FONT_FAMILY_SVG } from '../../../constants/icon-svg'
class FontFamilyMenu extends BaseMenu {
readonly title = t('fontFamily.title')
readonly iconSvg = FONT_FAMILY_SVG
readonly mark = 'fontFamily'
readonly selectPanelWidth = 150
getOptions(editor: IDomEditor): IOption[] {
const options: IOption[] = []
// 获取配置,参考 './config.ts'
const { fontFamilyList = [] } = editor.getMenuConfig(this.mark)
// 生成 options
options.push({
text: t('fontFamily.default'),
value: '', // this.getValue(editor) 未找到结果时,会返回 '' ,正好对应到这里
})
fontFamilyList.forEach((family: string | { name: string; value: string }) => {
if (typeof family === 'string') {
options.push({
text: family,
value: family,
styleForRenderMenuList: { 'font-family': family },
})
} else if (typeof family === 'object') {
const { name, value } = family
options.push({
text: name,
value,
styleForRenderMenuList: { 'font-family': value },
})
}
})
// 设置 selected
const curValue = this.getValue(editor)
options.forEach(opt => {
if (opt.value === curValue) {
opt.selected = true
} else {
delete opt.selected
}
})
return options
}
}
export default FontFamilyMenu
================================================
FILE: packages/basic-modules/src/modules/font-size-family/menu/FontSizeMenu.ts
================================================
/**
* @description font-size menu
* @author wangfupeng
*/
import { IDomEditor, IOption, t } from '@wangeditor/core'
import BaseMenu from './BaseMenu'
import { FONT_SIZE_SVG } from '../../../constants/icon-svg'
class FontSizeMenu extends BaseMenu {
readonly title = t('fontSize.title')
readonly iconSvg = FONT_SIZE_SVG
readonly mark = 'fontSize'
getOptions(editor: IDomEditor): IOption[] {
const options: IOption[] = []
// 获取配置,参考 './config.ts'
const { fontSizeList = [] } = editor.getMenuConfig(this.mark)
// 生成 options
options.push({
text: t('fontSize.default'),
value: '', // this.getValue(editor) 未找到结果时,会返回 '' ,正好对应到这里
})
fontSizeList.forEach((size: string | { name: string; value: string }) => {
if (typeof size === 'string') {
options.push({
text: size,
value: size,
})
} else if (typeof size === 'object') {
const { name, value } = size
options.push({
text: name,
value: value,
})
}
})
// 设置 selected
const curValue = this.getValue(editor)
options.forEach(opt => {
if (opt.value === curValue) {
opt.selected = true
} else {
delete opt.selected
}
})
return options
}
}
export default FontSizeMenu
================================================
FILE: packages/basic-modules/src/modules/font-size-family/menu/config.ts
================================================
/**
* @description font-size font-family config
* @author wangfupeng
*/
export function genFontSizeConfig() {
const fontSizeList: Array = [
// 元素支持两种形式:1. 字符串;2. { name: 'xxx', value: 'xxx' }
'12px',
{ name: '13px', value: '13px' },
'14px',
'15px',
'16px',
'19px',
{ name: '22px', value: '22px' },
'24px',
'29px',
'32px',
'40px',
'48px',
]
return fontSizeList
}
export function getFontFamilyConfig() {
let fontFamilyList: Array = [
// 元素支持两种形式:1. 字符串;2. { name: 'xxx', value: 'xxx' }
'黑体',
{ name: '仿宋', value: '仿宋' },
'楷体',
'标楷体',
'华文仿宋',
'华文楷体',
{ name: '宋体', value: '宋体' },
'微软雅黑',
'Arial',
'Tahoma',
'Verdana',
'Times New Roman',
'Courier New',
]
return fontFamilyList
}
================================================
FILE: packages/basic-modules/src/modules/font-size-family/menu/index.ts
================================================
/**
* @description font-size font-family menu entry
* @author wangfupeng
*/
import FontSizeMenu from './FontSizeMenu'
import FontFamilyMenu from './FontFamilyMenu'
import { genFontSizeConfig, getFontFamilyConfig } from './config'
export const fontSizeMenuConf = {
key: 'fontSize',
factory() {
return new FontSizeMenu()
},
// 默认的菜单菜单配置,将存储在 editorConfig.MENU_CONF[key] 中
// 创建编辑器时,可通过 editorConfig.MENU_CONF[key] = {...} 来修改
config: {
fontSizeList: genFontSizeConfig(),
},
}
export const fontFamilyMenuConf = {
key: 'fontFamily',
factory() {
return new FontFamilyMenu()
},
config: {
fontFamilyList: getFontFamilyConfig(),
},
}
================================================
FILE: packages/basic-modules/src/modules/font-size-family/parse-style-html.ts
================================================
/**
* @description parse style html
* @author wangfupeng
*/
import { Descendant, Text } from 'slate'
import { IDomEditor } from '@wangeditor/core'
import { FontSizeAndFamilyText } from './custom-types'
import $, { DOMElement, getStyleValue } from '../../utils/dom'
export function parseStyleHtml(text: DOMElement, node: Descendant, editor: IDomEditor): Descendant {
const $text = $(text)
if (!Text.isText(node)) return node
const textNode = node as FontSizeAndFamilyText
// -------- 处理 font-size --------
const { fontSizeList = [] } = editor.getMenuConfig('fontSize')
const fontSize = getStyleValue($text, 'font-size')
const includesSize =
fontSizeList.find(item => item.value && item.value === fontSize) ||
fontSizeList.includes(fontSize)
if (fontSize && includesSize) {
textNode.fontSize = fontSize
}
// -------- 处理 font-family --------
const { fontFamilyList = [] } = editor.getMenuConfig('fontFamily')
// 这里需要替换掉 ", css 设置 font-family,会将有空格的字体使用 " 包裹
const fontFamily = getStyleValue($text, 'font-family').replace(/"/g, '')
// getFontFamilyConfig 配置支持对象形式
const includesFamily =
fontFamilyList.find(item => item.value && item.value === fontFamily) ||
fontFamilyList.includes(fontFamily)
if (fontFamily && includesFamily) {
textNode.fontFamily = fontFamily
}
return textNode
}
================================================
FILE: packages/basic-modules/src/modules/font-size-family/pre-parse-html.ts
================================================
/**
* @description pre-parse html
* @author wangfupeng
*/
import $, { DOMElement, getTagName } from '../../utils/dom'
// V4 font-size 对应关系(V4 使用 xxx 格式)
const FONT_SIZE_MAP_FOR_V4 = {
'1': '12px',
'2': '14px',
'3': '16px',
'4': '19px',
'5': '24px',
'6': '32px',
'7': '48px',
}
/**
* pre-prase font ,兼容 V4
* @param fontElem fontElem
*/
function preParse(fontElem: DOMElement): DOMElement {
const $font = $(fontElem)
const tagName = getTagName($font)
if (tagName !== 'font') return fontElem
// 处理 size (V4 使用 xxx 格式)
const size = $font.attr('size') || ''
if (size) {
$font.removeAttr('size')
$font.css('font-size', FONT_SIZE_MAP_FOR_V4[size])
}
// 处理 face (V4 使用 xx 格式)
const face = $font.attr('face') || ''
if (face) {
$font.removeAttr('face')
$font.css('font-family', face)
}
return $font[0]
}
export const preParseHtmlConf = {
selector: 'font',
preParseHtml: preParse,
}
================================================
FILE: packages/basic-modules/src/modules/font-size-family/render-style.tsx
================================================
/**
* @description render font-size font-family style
* @author wangfupeng
*/
import { Descendant } from 'slate'
import { jsx, VNode } from 'snabbdom'
import { addVnodeStyle } from '../../utils/vdom'
import { FontSizeAndFamilyText } from './custom-types'
/**
* 添加样式
* @param node slate elem
* @param vnode vnode
* @returns vnode
*/
export function renderStyle(node: Descendant, vnode: VNode): VNode {
const { fontSize, fontFamily } = node as FontSizeAndFamilyText
let styleVnode: VNode = vnode
if (fontSize) {
addVnodeStyle(styleVnode, { fontSize })
}
if (fontFamily) {
addVnodeStyle(styleVnode, { fontFamily })
}
return styleVnode
}
================================================
FILE: packages/basic-modules/src/modules/font-size-family/style-to-html.ts
================================================
/**
* @description textStyle to html
* @author wangfupeng
*/
import { Text, Descendant } from 'slate'
import $, { getOuterHTML, getTagName, isPlainText } from '../../utils/dom'
import { FontSizeAndFamilyText } from './custom-types'
/**
* style to html
* @param textNode slate text node
* @param textHtml text html
* @returns styled html
*/
export function styleToHtml(textNode: Descendant, textHtml: string): string {
if (!Text.isText(textNode)) return textHtml
const { fontSize, fontFamily } = textNode as FontSizeAndFamilyText
if (!fontSize && !fontFamily) return textHtml
let $text
if (isPlainText(textHtml)) {
// textHtml 是纯文本,不是 html tag
$text = $(`${textHtml}`)
} else {
// textHtml 是 html tag
$text = $(textHtml)
const tagName = getTagName($text)
if (tagName !== 'span') {
// 如果不是 span ,则包裹一层,接下来要设置 css
$text = $(`${textHtml}`)
}
}
if (fontSize) $text.css('font-size', fontSize)
if (fontFamily) $text.css('font-family', fontFamily)
return getOuterHTML($text)
}
================================================
FILE: packages/basic-modules/src/modules/full-screen/index.ts
================================================
/**
* @description 全屏
* @author wangfupeng
*/
import { IModuleConf } from '@wangeditor/core'
import { fullScreenConf } from './menu/index'
const fullScreen: Partial = {
menus: [fullScreenConf],
}
export default fullScreen
================================================
FILE: packages/basic-modules/src/modules/full-screen/menu/FullScreen.ts
================================================
/**
* @description redo menu
* @author wangfupeng
*/
import { IButtonMenu, IDomEditor, t } from '@wangeditor/core'
import { FULL_SCREEN_SVG } from '../../../constants/icon-svg'
class FullScreen implements IButtonMenu {
title = t('fullScreen.title')
iconSvg = FULL_SCREEN_SVG
tag = 'button'
alwaysEnable = true
getValue(editor: IDomEditor): string | boolean {
return ''
}
isActive(editor: IDomEditor): boolean {
return editor.isFullScreen
}
isDisabled(editor: IDomEditor): boolean {
return false
}
exec(editor: IDomEditor, value: string | boolean) {
if (editor.isFullScreen) {
editor.unFullScreen()
} else {
editor.fullScreen()
}
}
}
export default FullScreen
================================================
FILE: packages/basic-modules/src/modules/full-screen/menu/index.ts
================================================
/**
* @description menu entry
* @author wangfupeng
*/
import FullScreen from './FullScreen'
export const fullScreenConf = {
key: 'fullScreen',
factory() {
return new FullScreen()
},
}
================================================
FILE: packages/basic-modules/src/modules/header/custom-types.ts
================================================
/**
* @description 自定义 element
* @author wangfupeng
*/
import { Text } from 'slate'
//【注意】需要把自定义的 Element 引入到最外层的 custom-types.d.ts
export type Header1Element = {
type: 'header1'
children: Text[]
}
export type Header2Element = {
type: 'header2'
children: Text[]
}
export type Header3Element = {
type: 'header3'
children: Text[]
}
export type Header4Element = {
type: 'header4'
children: Text[]
}
export type Header5Element = {
type: 'header5'
children: Text[]
}
================================================
FILE: packages/basic-modules/src/modules/header/elem-to-html.ts
================================================
/**
* @description to html
* @author wangfupeng
*/
import { Element } from 'slate'
function genToHtmlFn(level: number) {
function headerToHtml(elem: Element, childrenHtml: string): string {
return `${childrenHtml} `
}
return headerToHtml
}
export const header1ToHtmlConf = {
type: 'header1',
elemToHtml: genToHtmlFn(1),
}
export const header2ToHtmlConf = {
type: 'header2',
elemToHtml: genToHtmlFn(2),
}
export const header3ToHtmlConf = {
type: 'header3',
elemToHtml: genToHtmlFn(3),
}
export const header4ToHtmlConf = {
type: 'header4',
elemToHtml: genToHtmlFn(4),
}
export const header5ToHtmlConf = {
type: 'header5',
elemToHtml: genToHtmlFn(5),
}
================================================
FILE: packages/basic-modules/src/modules/header/helper.ts
================================================
/**
* @description header helper
* @author wangfupeng
*/
import { Editor, Transforms } from 'slate'
import { IDomEditor, DomEditor } from '@wangeditor/core'
/**
* 获取 node type('header1' 'header2' 等),未匹配则返回 'paragraph'
*/
export function getHeaderType(editor: IDomEditor): string {
const [match] = Editor.nodes(editor, {
match: n => {
const type = DomEditor.getNodeType(n)
return type.startsWith('header') // 匹配 node.type 是 header 开头的 node
},
universal: true,
})
// 未匹配到 header
if (match == null) return 'paragraph'
// 匹配到 header
const [n] = match
return DomEditor.getNodeType(n)
}
export function isMenuDisabled(editor: IDomEditor): boolean {
if (editor.selection == null) return true
const [nodeEntry] = Editor.nodes(editor, {
match: n => {
const type = DomEditor.getNodeType(n)
// 只可用于 p 和 header
if (type === 'paragraph') return true
if (type.startsWith('header')) return true
return false
},
universal: true,
mode: 'highest', // 匹配最高层级
})
// 匹配到 p header ,不禁用
if (nodeEntry) {
return false
}
// 未匹配到 p header ,则禁用
return true
}
/**
* 设置 node type ('header1' 'header2' 'paragraph' 等)
*/
export function setHeaderType(editor: IDomEditor, type: string) {
if (!type) return
// 执行命令
Transforms.setNodes(editor, {
type: type,
})
}
================================================
FILE: packages/basic-modules/src/modules/header/index.ts
================================================
/**
* @description header entry
* @author wangfupeng
*/
import { IModuleConf } from '@wangeditor/core'
import {
renderHeader1Conf,
renderHeader2Conf,
renderHeader3Conf,
renderHeader4Conf,
renderHeader5Conf,
} from './render-elem'
import {
HeaderSelectMenuConf,
Header1ButtonMenuConf,
Header2ButtonMenuConf,
Header3ButtonMenuConf,
Header4ButtonMenuConf,
Header5ButtonMenuConf,
} from './menu/index'
import {
header1ToHtmlConf,
header2ToHtmlConf,
header3ToHtmlConf,
header4ToHtmlConf,
header5ToHtmlConf,
} from './elem-to-html'
import {
parseHeader1HtmlConf,
parseHeader2HtmlConf,
parseHeader3HtmlConf,
parseHeader4HtmlConf,
parseHeader5HtmlConf,
} from './parse-elem-html'
import withHeader from './plugin'
const header: Partial = {
renderElems: [
renderHeader1Conf,
renderHeader2Conf,
renderHeader3Conf,
renderHeader4Conf,
renderHeader5Conf,
],
elemsToHtml: [
header1ToHtmlConf,
header2ToHtmlConf,
header3ToHtmlConf,
header4ToHtmlConf,
header5ToHtmlConf,
],
parseElemsHtml: [
parseHeader1HtmlConf,
parseHeader2HtmlConf,
parseHeader3HtmlConf,
parseHeader4HtmlConf,
parseHeader5HtmlConf,
],
menus: [
HeaderSelectMenuConf,
Header1ButtonMenuConf,
Header2ButtonMenuConf,
Header3ButtonMenuConf,
Header4ButtonMenuConf,
Header5ButtonMenuConf,
],
editorPlugin: withHeader,
}
export default header
================================================
FILE: packages/basic-modules/src/modules/header/menu/Header1ButtonMenu.ts
================================================
/**
* @description header1 button menu
* @author wangfupeng
*/
import HeaderButtonMenuBase from './HeaderButtonMenuBase'
class Header1ButtonMenu extends HeaderButtonMenuBase {
title = 'H1'
type = 'header1'
}
export default Header1ButtonMenu
================================================
FILE: packages/basic-modules/src/modules/header/menu/Header2ButtonMenu.ts
================================================
/**
* @description header2 button menu
* @author wangfupeng
*/
import HeaderButtonMenuBase from './HeaderButtonMenuBase'
class Header2ButtonMenu extends HeaderButtonMenuBase {
title = 'H2'
type = 'header2'
}
export default Header2ButtonMenu
================================================
FILE: packages/basic-modules/src/modules/header/menu/Header3ButtonMenu.ts
================================================
/**
* @description header3 button menu
* @author wangfupeng
*/
import HeaderButtonMenuBase from './HeaderButtonMenuBase'
class Header3ButtonMenu extends HeaderButtonMenuBase {
title = 'H3'
type = 'header3'
}
export default Header3ButtonMenu
================================================
FILE: packages/basic-modules/src/modules/header/menu/Header4ButtonMenu.ts
================================================
/**
* @description header4 button menu
* @author wangfupeng
*/
import HeaderButtonMenuBase from './HeaderButtonMenuBase'
class Header4ButtonMenu extends HeaderButtonMenuBase {
title = 'H4'
type = 'header4'
}
export default Header4ButtonMenu
================================================
FILE: packages/basic-modules/src/modules/header/menu/Header5ButtonMenu.ts
================================================
/**
* @description header5 button menu
* @author wangfupeng
*/
import HeaderButtonMenuBase from './HeaderButtonMenuBase'
class Header5ButtonMenu extends HeaderButtonMenuBase {
title = 'H5'
type = 'header5'
}
export default Header5ButtonMenu
================================================
FILE: packages/basic-modules/src/modules/header/menu/HeaderButtonMenuBase.ts
================================================
/**
* @description button menu base
* @author wangfupeng
*/
import { IButtonMenu, IDomEditor } from '@wangeditor/core'
import { getHeaderType, isMenuDisabled, setHeaderType } from '../helper'
abstract class HeaderButtonMenuBase implements IButtonMenu {
abstract readonly title: string
abstract readonly type: string // 'header1' 'header2' 等
readonly tag = 'button'
/**
* 获取选中节点的 node.type
* @param editor editor
*/
getValue(editor: IDomEditor): string | boolean {
return getHeaderType(editor)
}
isActive(editor: IDomEditor): boolean {
return this.getValue(editor) === this.type
}
isDisabled(editor: IDomEditor): boolean {
return isMenuDisabled(editor)
}
exec(editor: IDomEditor, value: string | boolean) {
const { type } = this
let newType
if (value === type) {
// 选中的 node.type 和当前 type 一样,则取消
newType = 'paragraph'
} else {
// 否则,则设置
newType = type
}
setHeaderType(editor, newType)
}
}
export default HeaderButtonMenuBase
================================================
FILE: packages/basic-modules/src/modules/header/menu/HeaderSelectMenu.ts
================================================
/**
* @description header menu
* @author wangfupeng
*/
import { ISelectMenu, IDomEditor, IOption, t } from '@wangeditor/core'
import { HEADER_SVG } from '../../../constants/icon-svg'
import { getHeaderType, isMenuDisabled, setHeaderType } from '../helper'
class HeaderSelectMenu implements ISelectMenu {
readonly title = t('header.title')
readonly iconSvg = HEADER_SVG
readonly tag = 'select'
readonly width = 60
getOptions(editor: IDomEditor): IOption[] {
// 基本的 options 列表
const options = [
// value 和 elemNode.type 对应
{
value: 'header1',
text: 'H1',
styleForRenderMenuList: { 'font-size': '32px', 'font-weight': 'bold' },
},
{
value: 'header2',
text: 'H2',
styleForRenderMenuList: { 'font-size': '24px', 'font-weight': 'bold' },
},
{
value: 'header3',
text: 'H3',
styleForRenderMenuList: { 'font-size': '18px', 'font-weight': 'bold' },
},
{
value: 'header4',
text: 'H4',
styleForRenderMenuList: { 'font-size': '16px', 'font-weight': 'bold' },
},
{
value: 'header5',
text: 'H5',
styleForRenderMenuList: { 'font-size': '13px', 'font-weight': 'bold' },
},
{ value: 'paragraph', text: t('header.text') },
]
// 获取 value ,设置 selected
const curValue = this.getValue(editor).toString()
options.forEach((opt: IOption) => {
if (opt.value === curValue) {
opt.selected = true
} else {
delete opt.selected
}
})
return options
}
isActive(editor: IDomEditor): boolean {
// select menu 会显示 selected value ,用不到 active
return false
}
/**
* 获取选中节点的 node.type
* @param editor editor
*/
getValue(editor: IDomEditor): string | boolean {
return getHeaderType(editor)
}
isDisabled(editor: IDomEditor): boolean {
return isMenuDisabled(editor)
}
/**
* 执行命令
* @param editor editor
* @param value node.type
*/
exec(editor: IDomEditor, value: string | boolean) {
//【注意】value 是 select change 时获取的,并不是 this.getValue 的值
setHeaderType(editor, value.toString())
}
}
export default HeaderSelectMenu
================================================
FILE: packages/basic-modules/src/modules/header/menu/index.ts
================================================
/**
* @description menu entry
* @author wangfupeng
*/
import HeaderSelectMenu from './HeaderSelectMenu'
import Header1ButtonMenu from './Header1ButtonMenu'
import Header2ButtonMenu from './Header2ButtonMenu'
import Header3ButtonMenu from './Header3ButtonMenu'
import Header4ButtonMenu from './Header4ButtonMenu'
import Header5ButtonMenu from './Header5ButtonMenu'
export const HeaderSelectMenuConf = {
key: 'headerSelect',
factory() {
return new HeaderSelectMenu()
},
}
export const Header1ButtonMenuConf = {
key: 'header1',
factory() {
return new Header1ButtonMenu()
},
}
export const Header2ButtonMenuConf = {
key: 'header2',
factory() {
return new Header2ButtonMenu()
},
}
export const Header3ButtonMenuConf = {
key: 'header3',
factory() {
return new Header3ButtonMenu()
},
}
export const Header4ButtonMenuConf = {
key: 'header4',
factory() {
return new Header4ButtonMenu()
},
}
export const Header5ButtonMenuConf = {
key: 'header5',
factory() {
return new Header5ButtonMenu()
},
}
================================================
FILE: packages/basic-modules/src/modules/header/parse-elem-html.ts
================================================
/**
* @description parse html
* @author wangfupeng
*/
import { Descendant, Text } from 'slate'
import $, { DOMElement } from '../../utils/dom'
import { IDomEditor } from '@wangeditor/core'
import {
Header1Element,
Header2Element,
Header3Element,
Header4Element,
Header5Element,
} from './custom-types'
function genParser(level: number) {
function parseHtml(elem: DOMElement, children: Descendant[], editor: IDomEditor): T {
const $elem = $(elem)
children = children.filter(child => {
if (Text.isText(child)) return true
if (editor.isInline(child)) return true
return false
})
// 无 children ,则用纯文本
if (children.length === 0) {
children = [{ text: $elem.text().replace(/\s+/gm, ' ') }]
}
const headerNode = {
type: `header${level}`,
children,
} as unknown as T
return headerNode
}
return parseHtml
}
export const parseHeader1HtmlConf = {
selector: 'h1:not([data-w-e-type])', // data-w-e-type 属性,留给自定义元素,保证扩展性
parseElemHtml: genParser(1),
}
export const parseHeader2HtmlConf = {
selector: 'h2:not([data-w-e-type])', // data-w-e-type 属性,留给自定义元素,保证扩展性
parseElemHtml: genParser(2),
}
export const parseHeader3HtmlConf = {
selector: 'h3:not([data-w-e-type])', // data-w-e-type 属性,留给自定义元素,保证扩展性
parseElemHtml: genParser(3),
}
export const parseHeader4HtmlConf = {
selector: 'h4:not([data-w-e-type])', // data-w-e-type 属性,留给自定义元素,保证扩展性
parseElemHtml: genParser(4),
}
export const parseHeader5HtmlConf = {
selector: 'h5:not([data-w-e-type])', // data-w-e-type 属性,留给自定义元素,保证扩展性
parseElemHtml: genParser(5),
}
================================================
FILE: packages/basic-modules/src/modules/header/plugin.ts
================================================
/**
* @description editor 插件,重写 editor API
* @author wangfupeng
*/
import { Editor, Transforms } from 'slate'
import { IDomEditor, DomEditor } from '@wangeditor/core'
function withHeader(editor: T): T {
const { insertBreak, insertNode } = editor
const newEditor = editor
// 重写 insertBreak - header 末尾回车时要插入 paragraph
newEditor.insertBreak = () => {
const [match] = Editor.nodes(newEditor, {
match: n => {
const type = DomEditor.getNodeType(n)
return type.startsWith('header') // 匹配 node.type 是 header 开头的 node
},
universal: true,
})
if (!match) {
// 未匹配到
insertBreak()
return
}
const isAtLineEnd = DomEditor.isSelectionAtLineEnd(editor, match[1])
// 如果在行末插入一个空 p,否则正常换行
if (isAtLineEnd) {
const p = { type: 'paragraph', children: [{ text: '' }] }
Transforms.insertNodes(newEditor, p, { mode: 'highest' })
} else {
insertBreak()
}
}
// 返回 editor ,重要!
return newEditor
}
export default withHeader
================================================
FILE: packages/basic-modules/src/modules/header/render-elem.tsx
================================================
/**
* @description render header
* @author wangfupeng
*/
import { Element as SlateElement } from 'slate'
import { jsx, VNode } from 'snabbdom'
import { IDomEditor } from '@wangeditor/core'
function genRenderElem(level: number) {
/**
* render header elem
* @param elemNode slate elem
* @param children children
* @param editor editor
* @returns vnode
*/
function renderHeader(
elemNode: SlateElement,
children: VNode[] | null,
editor: IDomEditor
): VNode {
const Tag = `h${level}`
const vnode = {children}
return vnode
}
return renderHeader
}
const renderHeader1Conf = {
type: 'header1', // 和 elemNode.type 一致
renderElem: genRenderElem(1),
}
const renderHeader2Conf = {
type: 'header2',
renderElem: genRenderElem(2),
}
const renderHeader3Conf = {
type: 'header3',
renderElem: genRenderElem(3),
}
const renderHeader4Conf = {
type: 'header4',
renderElem: genRenderElem(4),
}
const renderHeader5Conf = {
type: 'header5',
renderElem: genRenderElem(5),
}
export {
renderHeader1Conf,
renderHeader2Conf,
renderHeader3Conf,
renderHeader4Conf,
renderHeader5Conf,
}
================================================
FILE: packages/basic-modules/src/modules/image/custom-types.ts
================================================
/**
* @description image element
* @author wangfupeng
*/
//【注意】需要把自定义的 Element 引入到最外层的 custom-types.d.ts
type EmptyText = {
text: ''
}
export type ImageStyle = {
width?: string
height?: string
}
export type ImageElement = {
type: 'image'
src: string
alt?: string
href?: string
style?: ImageStyle
children: EmptyText[]
}
================================================
FILE: packages/basic-modules/src/modules/image/elem-to-html.ts
================================================
/**
* @description to html
* @author wangfupeng
*/
import { Element } from 'slate'
import { ImageElement } from './custom-types'
function imageToHtml(elemNode: Element, childrenHtml: string): string {
const { src, alt = '', href = '', style = {} } = elemNode as ImageElement
const { width = '', height = '' } = style
let styleStr = ''
if (width) styleStr += `width: ${width};`
if (height) styleStr += `height: ${height};`
return `
`
}
export const imageToHtmlConf = {
type: 'image',
elemToHtml: imageToHtml,
}
================================================
FILE: packages/basic-modules/src/modules/image/helper.ts
================================================
/**
* @description image menu helper
* @author wangfupeng
*/
import { Transforms, Range, Editor } from 'slate'
import { IDomEditor, DomEditor } from '@wangeditor/core'
import { ImageElement, ImageStyle } from './custom-types'
import { replaceSymbols } from '../../utils/util'
async function check(
menuKey: string,
editor: IDomEditor,
src: string,
alt: string = '',
href: string = ''
): Promise {
const { checkImage } = editor.getMenuConfig(menuKey)
if (checkImage) {
const res = await checkImage(src, alt, href)
if (typeof res === 'string') {
// 检验未通过,提示信息
editor.alert(res, 'error')
return false
}
if (res == null) {
// 检验未通过,不提示信息
return false
}
}
return true
}
async function parseSrc(menuKey: string, editor: IDomEditor, src: string): Promise {
const { parseImageSrc } = editor.getMenuConfig(menuKey)
if (parseImageSrc) {
const newSrc = await parseImageSrc(src)
return newSrc
}
return src
}
export async function insertImageNode(
editor: IDomEditor,
src: string,
alt: string = '',
href: string = ''
) {
const res = await check('insertImage', editor, src, alt, href)
if (!res) return // 检查失败,终止操作
const parsedSrc = await parseSrc('insertImage', editor, src)
// 新建一个 image node
const image: ImageElement = {
type: 'image',
src: replaceSymbols(parsedSrc),
href,
alt,
style: {},
children: [{ text: '' }], // 【注意】void node 需要一个空 text 作为 children
}
// 如果 blur ,则恢复选区
if (editor.selection === null) editor.restoreSelection()
// 如果当前正好选中了图片,则 move 一下(如:连续上传多张图片时)
if (DomEditor.getSelectedNodeByType(editor, 'image')) {
editor.move(1)
}
if (isInsertImageMenuDisabled(editor)) return
// 插入图片
Transforms.insertNodes(editor, image)
// 回调
const { onInsertedImage } = editor.getMenuConfig('insertImage')
if (onInsertedImage) onInsertedImage(image)
}
export async function updateImageNode(
editor: IDomEditor,
src: string,
alt: string = '',
href: string = '',
style: ImageStyle = {}
) {
const res = await check('editImage', editor, src, alt, href)
if (!res) return // 检查失败,终止操作
const parsedSrc = await parseSrc('editImage', editor, src)
const selectedImageNode = DomEditor.getSelectedNodeByType(editor, 'image')
if (selectedImageNode == null) return
const { style: curStyle = {} } = selectedImageNode as ImageElement
// 修改图片
const nodeProps: Partial = {
src: parsedSrc,
alt,
href,
style: {
...curStyle,
...style,
},
}
Transforms.setNodes(editor, nodeProps, {
match: n => DomEditor.checkNodeType(n, 'image'),
})
// 回调
const imageNode = DomEditor.getSelectedNodeByType(editor, 'image')
const { onUpdatedImage } = editor.getMenuConfig('editImage')
if (onUpdatedImage) onUpdatedImage(imageNode)
}
/**
* 判断菜单是否要 disabled
* @param editor editor
*/
export function isInsertImageMenuDisabled(editor: IDomEditor): boolean {
const { selection } = editor
if (selection == null) return true
if (!Range.isCollapsed(selection)) return true // 选区非折叠,禁用
const [match] = Editor.nodes(editor, {
match: n => {
const type = DomEditor.getNodeType(n)
if (type === 'code') return true // 代码块
if (type === 'pre') return true // 代码块
if (type === 'link') return true // 链接
if (type === 'list-item') return true // list
if (type.startsWith('header')) return true // 标题
if (type === 'blockquote') return true // 引用
if (Editor.isVoid(editor, n)) return true // void
return false
},
universal: true,
})
if (match) return true
return false
}
================================================
FILE: packages/basic-modules/src/modules/image/index.ts
================================================
/**
* @description image module entry
* @author wangfupeng
*/
import { IModuleConf } from '@wangeditor/core'
import withImage from './plugin'
import { renderImageConf } from './render-elem'
import { imageToHtmlConf } from './elem-to-html'
import { parseHtmlConf } from './parse-elem-html'
import {
insertImageMenuConf,
deleteImageMenuConf,
editImageMenuConf,
viewImageLinkMenuConf,
imageWidth30MenuConf,
imageWidth50MenuConf,
imageWidth100MenuConf,
} from './menu/index'
const image: Partial = {
renderElems: [renderImageConf],
elemsToHtml: [imageToHtmlConf],
parseElemsHtml: [parseHtmlConf],
menus: [
insertImageMenuConf,
deleteImageMenuConf,
editImageMenuConf,
viewImageLinkMenuConf,
imageWidth30MenuConf,
imageWidth50MenuConf,
imageWidth100MenuConf,
],
editorPlugin: withImage,
}
export default image
================================================
FILE: packages/basic-modules/src/modules/image/menu/DeleteImage.ts
================================================
/**
* @description delete image menu
* @author wangfupeng
*/
import { Transforms } from 'slate'
import { IButtonMenu, IDomEditor, DomEditor, t } from '@wangeditor/core'
import { TRASH_SVG } from '../../../constants/icon-svg'
class DeleteImage implements IButtonMenu {
readonly title = t('image.delete')
readonly iconSvg = TRASH_SVG
readonly tag = 'button'
getValue(editor: IDomEditor): string | boolean {
// 无需获取 val
return ''
}
isActive(editor: IDomEditor): boolean {
// 无需 active
return false
}
isDisabled(editor: IDomEditor): boolean {
if (editor.selection == null) return true
const imageNode = DomEditor.getSelectedNodeByType(editor, 'image')
if (imageNode == null) {
// 选区未处于 image node ,则禁用
return true
}
return false
}
exec(editor: IDomEditor, value: string | boolean) {
if (this.isDisabled(editor)) return
// 删除图片
Transforms.removeNodes(editor, {
match: n => DomEditor.checkNodeType(n, 'image'),
})
}
}
export default DeleteImage
================================================
FILE: packages/basic-modules/src/modules/image/menu/EditImage.ts
================================================
/**
* @description editor image menu
* @author wangfupeng
*/
import { Node, Range } from 'slate'
import {
IModalMenu,
IDomEditor,
DomEditor,
genModalInputElems,
genModalButtonElems,
t,
} from '@wangeditor/core'
import $, { Dom7Array, DOMElement } from '../../../utils/dom'
import { genRandomStr } from '../../../utils/util'
import { PENCIL_SVG } from '../../../constants/icon-svg'
import { updateImageNode } from '../helper'
import { ImageElement, ImageStyle } from '../custom-types'
/**
* 生成唯一的 DOM ID
*/
function genDomID(): string {
return genRandomStr('w-e-edit-image')
}
class EditImage implements IModalMenu {
readonly title = t('image.edit')
readonly iconSvg = PENCIL_SVG
readonly tag = 'button'
readonly showModal = true // 点击 button 时显示 modal
readonly modalWidth = 300
private $content: Dom7Array | null = null
private readonly srcInputId = genDomID()
private readonly altInputId = genDomID()
private readonly hrefInputId = genDomID()
private readonly buttonId = genDomID()
getValue(editor: IDomEditor): string | boolean {
// 编辑图片,用不到 getValue
return ''
}
private getImageNode(editor: IDomEditor): Node | null {
return DomEditor.getSelectedNodeByType(editor, 'image')
}
isActive(editor: IDomEditor): boolean {
// 无需 active
return false
}
exec(editor: IDomEditor, value: string | boolean) {
// 点击菜单时,弹出 modal 之前,不需要执行其他代码
// 此处空着即可
}
isDisabled(editor: IDomEditor): boolean {
const { selection } = editor
if (selection == null) return true
if (!Range.isCollapsed(selection)) return true // 选区非折叠,禁用
const imageNode = DomEditor.getSelectedNodeByType(editor, 'image')
// 未匹配到 image node 则禁用
if (imageNode == null) return true
return false
}
getModalPositionNode(editor: IDomEditor): Node | null {
return this.getImageNode(editor)
}
getModalContentElem(editor: IDomEditor): DOMElement {
const { srcInputId, altInputId, hrefInputId, buttonId } = this
const selectedImageNode = this.getImageNode(editor)
if (selectedImageNode == null) {
throw new Error('Not found selected image node')
}
// 获取 input button elem
const [srcContainerElem, inputSrcElem] = genModalInputElems(t('image.src'), srcInputId)
const $inputSrc = $(inputSrcElem)
const [altContainerElem, inputAltElem] = genModalInputElems(t('image.desc'), altInputId)
const $inputAlt = $(inputAltElem)
const [hrefContainerElem, inputHrefElem] = genModalInputElems(t('image.link'), hrefInputId)
const $inputHref = $(inputHrefElem)
const [buttonContainerElem] = genModalButtonElems(buttonId, t('common.ok'))
if (this.$content == null) {
// 第一次渲染
const $content = $('')
// 绑定事件(第一次渲染时绑定,不要重复绑定)
$content.on('click', `#${buttonId}`, e => {
e.preventDefault()
const src = $content.find(`#${srcInputId}`).val()
const alt = $content.find(`#${altInputId}`).val()
const href = $content.find(`#${hrefInputId}`).val()
this.updateImage(editor, src, alt, href)
editor.hidePanelOrModal() // 隐藏 modal
})
// 记录属性,重要
this.$content = $content
}
const $content = this.$content
$content.empty() // 先清空内容
// append inputs and button
$content.append(srcContainerElem)
$content.append(altContainerElem)
$content.append(hrefContainerElem)
$content.append(buttonContainerElem)
// 设置 input val
const { src, alt = '', href = '' } = selectedImageNode as ImageElement
$inputSrc.val(src)
$inputAlt.val(alt)
$inputHref.val(href)
// focus 一个 input(异步,此时 DOM 尚未渲染)
setTimeout(() => {
$inputSrc.focus()
})
return $content[0]
}
private updateImage(
editor: IDomEditor,
src: string,
alt: string = '',
href: string = '',
style: ImageStyle = {}
) {
if (!src) return
// 还原选区
editor.restoreSelection()
if (this.isDisabled(editor)) return
// 修改图片信息
updateImageNode(editor, src, alt, href, style)
}
}
export default EditImage
================================================
FILE: packages/basic-modules/src/modules/image/menu/InsertImage.ts
================================================
/**
* @description insert image menu
* @author wangfupeng
*/
import { Node } from 'slate'
import {
IModalMenu,
IDomEditor,
genModalInputElems,
genModalButtonElems,
t,
} from '@wangeditor/core'
import $, { Dom7Array, DOMElement } from '../../../utils/dom'
import { genRandomStr } from '../../../utils/util'
import { IMAGE_SVG } from '../../../constants/icon-svg'
import { insertImageNode, isInsertImageMenuDisabled } from '../helper'
/**
* 生成唯一的 DOM ID
*/
function genDomID(): string {
return genRandomStr('w-e-insert-image')
}
class InsertImage implements IModalMenu {
readonly title = t('image.netImage')
readonly iconSvg = IMAGE_SVG
readonly tag = 'button'
readonly showModal = true // 点击 button 时显示 modal
readonly modalWidth = 300
private $content: Dom7Array | null = null
private readonly srcInputId = genDomID()
private readonly altInputId = genDomID()
private readonly hrefInputId = genDomID()
private readonly buttonId = genDomID()
getValue(editor: IDomEditor): string | boolean {
// 插入菜单,不需要 value
return ''
}
isActive(editor: IDomEditor): boolean {
// 任何时候,都不用激活 menu
return false
}
exec(editor: IDomEditor, value: string | boolean) {
// 点击菜单时,弹出 modal 之前,不需要执行其他代码
// 此处空着即可
}
isDisabled(editor: IDomEditor): boolean {
return isInsertImageMenuDisabled(editor)
}
getModalPositionNode(editor: IDomEditor): Node | null {
return null // modal 依据选区定位
}
getModalContentElem(editor: IDomEditor): DOMElement {
const { srcInputId, altInputId, hrefInputId, buttonId } = this
// 获取 input button elem
const [srcContainerElem, inputSrcElem] = genModalInputElems(t('image.src'), srcInputId)
const $inputSrc = $(inputSrcElem)
const [altContainerElem, inputAltElem] = genModalInputElems(t('image.desc'), altInputId)
const $inputAlt = $(inputAltElem)
const [hrefContainerElem, inputHrefElem] = genModalInputElems(t('image.link'), hrefInputId)
const $inputHref = $(inputHrefElem)
const [buttonContainerElem] = genModalButtonElems(buttonId, t('common.ok'))
if (this.$content == null) {
// 第一次渲染
const $content = $('')
// 绑定事件(第一次渲染时绑定,不要重复绑定)
$content.on('click', `#${buttonId}`, e => {
e.preventDefault()
const src = $content.find(`#${srcInputId}`).val().trim()
const alt = $content.find(`#${altInputId}`).val().trim()
const href = $content.find(`#${hrefInputId}`).val().trim()
this.insertImage(editor, src, alt, href)
editor.hidePanelOrModal() // 隐藏 modal
})
// 记录属性,重要
this.$content = $content
}
const $content = this.$content
$content.empty() // 先清空内容
// append inputs and button
$content.append(srcContainerElem)
$content.append(altContainerElem)
$content.append(hrefContainerElem)
$content.append(buttonContainerElem)
// 设置 input val
$inputSrc.val('')
$inputAlt.val('')
$inputHref.val('')
// focus 一个 input(异步,此时 DOM 尚未渲染)
setTimeout(() => {
$inputSrc.focus()
})
return $content[0]
}
private insertImage(editor: IDomEditor, src: string, alt: string = '', href: string = '') {
if (!src) return
// 还原选区
editor.restoreSelection()
if (this.isDisabled(editor)) return
// 插入图片
insertImageNode(editor, src, alt, href)
}
}
export default InsertImage
================================================
FILE: packages/basic-modules/src/modules/image/menu/ViewImageLink.ts
================================================
/**
* @description view image link menu
* @author wangfupeng
*/
import { IButtonMenu, IDomEditor, DomEditor, t } from '@wangeditor/core'
import { EXTERNAL_SVG } from '../../../constants/icon-svg'
import { ImageElement } from '../custom-types'
class ViewImageLink implements IButtonMenu {
readonly title = t('image.viewLink')
readonly iconSvg = EXTERNAL_SVG
readonly tag = 'button'
getValue(editor: IDomEditor): string | boolean {
const imageNode = DomEditor.getSelectedNodeByType(editor, 'image')
if (imageNode) {
// 选区处于 image node
return (imageNode as ImageElement).href || ''
}
return ''
}
isActive(editor: IDomEditor): boolean {
// 无需 active
return false
}
isDisabled(editor: IDomEditor): boolean {
if (editor.selection == null) return true
const href = this.getValue(editor)
if (href) {
// 有 image href ,则不禁用
return false
}
return true
}
exec(editor: IDomEditor, value: string | boolean) {
if (this.isDisabled(editor)) return
if (!value || typeof value !== 'string') {
throw new Error(`View image link failed, image.href is '${value}'`)
return
}
// 查看链接
window.open(value, '_blank')
}
}
export default ViewImageLink
================================================
FILE: packages/basic-modules/src/modules/image/menu/Width100.ts
================================================
/**
* @description image width 100%
* @author wangfupeng
*/
import ImageWidthBaseClass from './WidthBase'
class ImageWidth100 extends ImageWidthBaseClass {
readonly title = '100%' // 菜单标题
readonly value = '100%' // css width 的值
}
export default ImageWidth100
================================================
FILE: packages/basic-modules/src/modules/image/menu/Width30.ts
================================================
/**
* @description image width 30%
* @author wangfupeng
*/
import ImageWidthBaseClass from './WidthBase'
class ImageWidth30 extends ImageWidthBaseClass {
readonly title = '30%' // 菜单标题
readonly value = '30%' // css width 的值
}
export default ImageWidth30
================================================
FILE: packages/basic-modules/src/modules/image/menu/Width50.ts
================================================
/**
* @description image width 50%
* @author wangfupeng
*/
import ImageWidthBaseClass from './WidthBase'
class ImageWidth50 extends ImageWidthBaseClass {
readonly title = '50%' // 菜单标题
readonly value = '50%' // css width 的值
}
export default ImageWidth50
================================================
FILE: packages/basic-modules/src/modules/image/menu/WidthBase.ts
================================================
/**
* @description image width base class
* @author wangfupeng
*/
import { Transforms, Node } from 'slate'
import { IButtonMenu, IDomEditor, DomEditor } from '@wangeditor/core'
import { ImageElement } from '../custom-types'
abstract class ImageWidthBaseClass implements IButtonMenu {
abstract readonly title: string // 菜单标题
readonly tag = 'button'
abstract readonly value: string // css width 的值
getValue(editor: IDomEditor): string | boolean {
// 无需获取 val
return ''
}
isActive(editor: IDomEditor): boolean {
// 无需 active
return false
}
private getSelectedNode(editor: IDomEditor): Node | null {
return DomEditor.getSelectedNodeByType(editor, 'image')
}
isDisabled(editor: IDomEditor): boolean {
if (editor.selection == null) return true
const imageNode = this.getSelectedNode(editor)
if (imageNode == null) {
// 选区未处于 image node ,则禁用
return true
}
return false
}
exec(editor: IDomEditor, value: string | boolean) {
if (this.isDisabled(editor)) return
const imageNode = this.getSelectedNode(editor)
if (imageNode == null) return
// 隐藏 hoverbar
const hoverbar = DomEditor.getHoverbar(editor)
if (hoverbar) hoverbar.hideAndClean()
const { style = {} } = imageNode as ImageElement
const props: Partial = {
style: {
...style,
width: this.value, // 修改 width
height: '', // 清空 height
},
}
Transforms.setNodes(editor, props, {
match: n => DomEditor.checkNodeType(n, 'image'),
})
}
}
export default ImageWidthBaseClass
================================================
FILE: packages/basic-modules/src/modules/image/menu/config.ts
================================================
/**
* @description 图片菜单配置
* @author wangfupeng
*/
import { ImageElement } from '../custom-types'
export function genImageMenuConfig() {
return {
/**
* 插入图片之后的回调
* @param imageElem ImageElement
*/
onInsertedImage(imageElem: ImageElement) {
/*自定义*/
},
/**
* 更新图片之后的回调
* @param node image node
*/
onUpdatedImage(node: ImageElement | null) {
/*自定义*/
},
/**
* 检查图片信息,支持 async fn
* @param src image src
* @param alt image alt
* @param href image href
*/
checkImage(src: string, alt: string, href: string): boolean | string | undefined {
// 1. 返回 true ,说明检查通过
// 2. 返回一个字符串,说明检查未通过,编辑器会阻止图片插入。会 alert 出错误信息(即返回的字符串)
// 3. 返回 undefined(即没有任何返回),说明检查未通过,编辑器会阻止图片插入
return true
},
/**
* parse image src
* @param src image src
* @returns new src
*/
parseImageSrc(src: string): string {
return src
},
}
}
================================================
FILE: packages/basic-modules/src/modules/image/menu/index.ts
================================================
/**
* @description image menu entry
* @author wangfupeng
*/
import InsertImage from './InsertImage'
import DeleteImage from './DeleteImage'
import EditImage from './EditImage'
import ViewImageLink from './ViewImageLink'
import ImageWidth30 from './Width30'
import ImageWidth50 from './Width50'
import ImageWidth100 from './Width100'
import { genImageMenuConfig } from './config'
const config = genImageMenuConfig() // menu config
export const insertImageMenuConf = {
key: 'insertImage',
factory() {
return new InsertImage()
},
// 默认的菜单菜单配置,将存储在 editorConfig.MENU_CONF[key] 中
// 创建编辑器时,可通过 editorConfig.MENU_CONF[key] = {...} 来修改
config,
}
export const deleteImageMenuConf = {
key: 'deleteImage',
factory() {
return new DeleteImage()
},
}
export const editImageMenuConf = {
key: 'editImage',
factory() {
return new EditImage()
},
config,
}
export const viewImageLinkMenuConf = {
key: 'viewImageLink',
factory() {
return new ViewImageLink()
},
}
export const imageWidth30MenuConf = {
key: 'imageWidth30',
factory() {
return new ImageWidth30()
},
}
export const imageWidth50MenuConf = {
key: 'imageWidth50',
factory() {
return new ImageWidth50()
},
}
export const imageWidth100MenuConf = {
key: 'imageWidth100',
factory() {
return new ImageWidth100()
},
}
================================================
FILE: packages/basic-modules/src/modules/image/parse-elem-html.ts
================================================
/**
* @description parse html
* @author wangfupeng
*/
import { Descendant } from 'slate'
import { IDomEditor } from '@wangeditor/core'
import { ImageElement } from './custom-types'
import $, { DOMElement, getStyleValue } from '../../utils/dom'
function parseHtml(elem: DOMElement, children: Descendant[], editor: IDomEditor): ImageElement {
const $elem = $(elem)
let href = $elem.attr('data-href') || ''
href = decodeURIComponent(href) // 兼容 V4
return {
type: 'image',
src: $elem.attr('src') || '',
alt: $elem.attr('alt') || '',
href,
style: {
width: getStyleValue($elem, 'width'),
height: getStyleValue($elem, 'height'),
},
children: [{ text: '' }], // void node 有一个空白 text
}
}
export const parseHtmlConf = {
selector: 'img:not([data-w-e-type])', // data-w-e-type 属性,留给自定义元素,保证扩展性
parseElemHtml: parseHtml,
}
================================================
FILE: packages/basic-modules/src/modules/image/plugin.ts
================================================
/**
* @description editor 插件,重写 editor API
* @author wangfupeng
*/
// import { Editor, Path, Operation } from 'slate'
import { IDomEditor } from '@wangeditor/core'
function withImage(editor: T): T {
const { isInline, isVoid, insertNode } = editor
const newEditor = editor
// 重写 isInline
newEditor.isInline = elem => {
const { type } = elem
if (type === 'image') {
return true
}
return isInline(elem)
}
// 重写 isVoid
newEditor.isVoid = elem => {
const { type } = elem
if (type === 'image') {
return true
}
return isVoid(elem)
}
// 返回 editor ,重要!
return newEditor
}
export default withImage
================================================
FILE: packages/basic-modules/src/modules/image/render-elem.tsx
================================================
/**
* @description image render elem
* @author wangfupeng
*/
import throttle from 'lodash.throttle'
import { Element as SlateElement, Transforms } from 'slate'
import { jsx, VNode } from 'snabbdom'
import { IDomEditor, DomEditor } from '@wangeditor/core'
import $, { Dom7Array } from '../../utils/dom'
import { ImageElement } from './custom-types'
interface IImageSize {
width?: string
height?: string
}
function genContainerId(editor: IDomEditor, elemNode: SlateElement) {
const { id } = DomEditor.findKey(editor, elemNode) // node 唯一 id
return `w-e-image-container-${id}`
}
/**
* 未选中时,渲染 image container
*/
function renderContainer(
editor: IDomEditor,
elemNode: SlateElement,
imageVnode: VNode,
imageInfo: IImageSize
): VNode {
const { width, height } = imageInfo
const style: any = {}
if (width) style.width = width
if (height) style.height = height
const containerId = genContainerId(editor, elemNode)
return (
{imageVnode}
)
}
/**
* 选中状态下,渲染 image container(渲染拖拽容器,修改图片尺寸)
*/
function renderResizeContainer(
editor: IDomEditor,
elemNode: SlateElement,
imageVnode: VNode,
imageInfo: IImageSize
) {
const $body = $('body')
const containerId = genContainerId(editor, elemNode)
const { width, height } = imageInfo
let originalX = 0
let originalWith = 0
let originalHeight = 0
let revers = false // 是否反转。如向右拖拽 right-top 需增加宽度(非反转),但向右拖拽 left-top 则需要减少宽度(反转)
let $container: Dom7Array | null = null
function getContainerElem(): Dom7Array {
const $container = $(`#${containerId}`)
if ($container.length === 0) throw new Error('Cannot find image container elem')
return $container
}
/**
* 初始化。监听事件,记录原始数据
*/
function init(clientX: number) {
$container = getContainerElem()
// 记录当前 x 坐标值
originalX = clientX
// 记录 img 原始宽高
const $img = $container.find('img')
if ($img.length === 0) throw new Error('Cannot find image elem')
originalWith = $img.width()
originalHeight = $img.height()
// 监听 mousemove
$body.on('mousemove', onMousemove)
// 监听 mouseup
$body.on('mouseup', onMouseup)
// 隐藏 hoverbar
const hoverbar = DomEditor.getHoverbar(editor)
if (hoverbar) hoverbar.hideAndClean()
}
// mouseover callback (节流)
const onMousemove = throttle((e: Event) => {
e.preventDefault()
const { clientX } = e as MouseEvent
const gap = revers ? originalX - clientX : clientX - originalX // 考虑是否反转
const newWidth = originalWith + gap
const newHeight = originalHeight * (newWidth / originalWith) // 根据 width ,按比例计算 height
// 实时修改 img 宽高 -【注意】这里只修改 DOM ,mouseup 时再统一不修改 node
if ($container == null) return
if (newWidth <= 15 || newHeight <= 15) return // 最小就是 15px
$container.css('width', `${newWidth}px`)
$container.css('height', `${newHeight}px`)
}, 100)
function onMouseup(e: Event) {
// 取消监听 mousemove
$body.off('mousemove', onMousemove)
if ($container == null) return
const newWidth = $container.width().toFixed(2)
const newHeight = $container.height().toFixed(2)
// 修改 node
const props: Partial = {
style: {
...(elemNode as ImageElement).style,
width: `${newWidth}px`,
height: `${newHeight}px`,
},
}
Transforms.setNodes(editor, props, { at: DomEditor.findPath(editor, elemNode) })
// 取消监听 mouseup
$body.off('mouseup', onMouseup)
}
const style: any = {}
if (width) style.width = width
if (height) style.height = height
// style.boxShadow = '0 0 0 1px #B4D5FF' // 自定义 selected 样式,因为有拖拽触手
return (
{
const $target = $(e.target as Element)
if (!$target.hasClass('w-e-image-dragger')) {
// target 不是 .w-e-image-dragger 拖拽触手,则忽略
return
}
e.preventDefault()
if ($target.hasClass('left-top') || $target.hasClass('left-bottom')) {
revers = true // 反转。向右拖拽,减少宽度
}
init(e.clientX) // 初始化
},
}}
>
{imageVnode}
{/* 拖拽的触手,会统一在上级 DOM 绑定拖拽事件 */}
)
}
function renderImage(elemNode: SlateElement, children: VNode[] | null, editor: IDomEditor): VNode {
const { src, alt = '', href = '', style = {} } = elemNode as ImageElement
const { width = '', height = '' } = style
const selected = DomEditor.isNodeSelected(editor, elemNode) // 图片是否选中
const imageStyle: any = {}
if (width) imageStyle.width = '100%'
if (height) imageStyle.height = '100%'
// 【注意】void node 中,renderElem 不用处理 children 。core 会统一处理。
const vnode =
const isDisabled = editor.isDisabled()
if (selected && !isDisabled) {
// 选中,未禁用 - 渲染 resize container
return renderResizeContainer(editor, elemNode, vnode, { width, height })
}
// 其他,渲染普通 image container
return renderContainer(editor, elemNode, vnode, { width, height })
}
const renderImageConf = {
type: 'image', // 和 elemNode.type 一致
renderElem: renderImage,
}
export { renderImageConf }
================================================
FILE: packages/basic-modules/src/modules/indent/custom-types.ts
================================================
/**
* @description 自定义 element
* @author wangfupeng
*/
import { Text } from 'slate'
//【注意】需要把自定义的 Element 引入到最外层的 custom-types.d.ts
export type IndentElement = {
type: string
indent?: string | null
children: Text[]
}
================================================
FILE: packages/basic-modules/src/modules/indent/index.ts
================================================
/**
* @description indent entry
* @author wangfupeng
*/
import { IModuleConf } from '@wangeditor/core'
import { renderStyle } from './render-style'
import { styleToHtml } from './style-to-html'
import { preParseHtmlConf } from './pre-parse-html'
import { parseStyleHtml } from './parse-style-html'
import { indentMenuConf, delIndentMenuConf } from './menu/index'
const indent: Partial = {
renderStyle,
styleToHtml,
preParseHtml: [preParseHtmlConf],
parseStyleHtml,
menus: [indentMenuConf, delIndentMenuConf],
}
export default indent
================================================
FILE: packages/basic-modules/src/modules/indent/menu/BaseMenu.ts
================================================
/**
* @description indent base menu
* @author wangfupeng
*/
import { Editor, Node } from 'slate'
import { IButtonMenu, IDomEditor, DomEditor } from '@wangeditor/core'
abstract class BaseMenu implements IButtonMenu {
abstract readonly title: string
abstract readonly iconSvg: string
readonly tag = 'button'
/**
* 获取 node.indent 的值,如 `2em`
* @param editor editor
*/
getValue(editor: IDomEditor): string | boolean {
const [nodeEntry] = Editor.nodes(editor, {
// @ts-ignore
match: n => !!n.indent,
universal: true,
})
if (nodeEntry == null) return ''
const [n] = nodeEntry
// @ts-ignore
return n.indent || ''
}
isActive(editor: IDomEditor): boolean {
// 不需要 active
return false
}
/**
* 获取 node 节点
* @param editor editor
*/
protected getMatchNode(editor: IDomEditor): Node | null {
const [nodeEntry] = Editor.nodes(editor, {
match: n => {
const type = DomEditor.getNodeType(n)
// 只可用于 p 和 header
if (type === 'paragraph') return true
if (type.startsWith('header')) return true
return false
},
universal: true,
mode: 'highest', // 匹配最高层级
})
if (nodeEntry == null) return null
return nodeEntry[0]
}
abstract isDisabled(editor: IDomEditor): boolean
abstract exec(editor: IDomEditor, value: string | boolean): void
}
export default BaseMenu
================================================
FILE: packages/basic-modules/src/modules/indent/menu/DecreaseIndentMenu.ts
================================================
/**
* @description 减少缩进
* @author wangfupeng
*/
import { Transforms, Element } from 'slate'
import { IDomEditor, t } from '@wangeditor/core'
import BaseMenu from './BaseMenu'
import { INDENT_LEFT_SVG } from '../../../constants/icon-svg'
import { IndentElement } from '../custom-types'
class DecreaseIndentMenu extends BaseMenu {
readonly title = t('indent.decrease')
readonly iconSvg = INDENT_LEFT_SVG
isDisabled(editor: IDomEditor): boolean {
const matchNode = this.getMatchNode(editor)
if (matchNode == null) return true // 未匹配 p header 等,则禁用
const { indent } = matchNode as IndentElement
if (!indent) {
// 没有 indent ,则禁用
return true
}
return false // 其他情况,不禁用
}
exec(editor: IDomEditor, value: string | boolean): void {
Transforms.setNodes(
editor,
{
indent: null,
},
{ match: n => Element.isElement(n) }
)
}
}
export default DecreaseIndentMenu
================================================
FILE: packages/basic-modules/src/modules/indent/menu/IncreaseIndentMenu.ts
================================================
/**
* @description 增加缩进
* @author wangfupeng
*/
import { Transforms, Element, Editor, Text } from 'slate'
import { IDomEditor, t, DomEditor } from '@wangeditor/core'
import BaseMenu from './BaseMenu'
import { INDENT_RIGHT_SVG } from '../../../constants/icon-svg'
import { IndentElement } from '../custom-types'
import type { FontSizeAndFamilyText } from '../../font-size-family/custom-types'
class IncreaseIndentMenu extends BaseMenu {
readonly title = t('indent.increase')
readonly iconSvg = INDENT_RIGHT_SVG
private DEFAULT_INDENT_VALUE = '2em'
isDisabled(editor: IDomEditor): boolean {
const matchNode = this.getMatchNode(editor)
if (matchNode == null) return true // 未匹配 p header 等,则禁用
const { indent } = matchNode as IndentElement
if (indent) {
// 有 indent ,则禁用
return true
}
return false
}
private getIndentValue(editor: IDomEditor) {
const matchNode = this.getMatchNode(editor)
if (!matchNode) return this.DEFAULT_INDENT_VALUE
const textChildren = (matchNode as Element).children.filter(Text.isText)
const lastTextNode = textChildren[0] as FontSizeAndFamilyText
if (!lastTextNode || !lastTextNode.fontSize) return this.DEFAULT_INDENT_VALUE
// 如果段落的第一个 Text 节点 设置了 fontSize 样式,indent 值需要根据 fontSize 进行计算
const fontSize = lastTextNode.fontSize
const value = parseInt(lastTextNode.fontSize)
const unit = fontSize.replace(`${value}`, '')
return `${value * 2}${unit}`
}
exec(editor: IDomEditor, value: string | boolean): void {
const indent = this.getIndentValue(editor)
Transforms.setNodes(
editor,
{
indent,
},
{
match: n => Element.isElement(n),
mode: 'highest',
}
)
}
}
export default IncreaseIndentMenu
================================================
FILE: packages/basic-modules/src/modules/indent/menu/index.ts
================================================
/**
* @description indent menu entry
* @author wangfupeng
*/
import DecreaseIndentMenu from './DecreaseIndentMenu'
import IncreaseIndentMenu from './IncreaseIndentMenu'
export const indentMenuConf = {
key: 'indent',
factory() {
return new IncreaseIndentMenu()
},
}
export const delIndentMenuConf = {
key: 'delIndent',
factory() {
return new DecreaseIndentMenu()
},
}
================================================
FILE: packages/basic-modules/src/modules/indent/parse-style-html.ts
================================================
/**
* @description parse style html
* @author wangfupeng
*/
import { Descendant, Element } from 'slate'
import { IDomEditor } from '@wangeditor/core'
import { IndentElement } from './custom-types'
import $, { DOMElement, getStyleValue } from '../../utils/dom'
export function parseStyleHtml(elem: DOMElement, node: Descendant, editor: IDomEditor): Descendant {
const $elem = $(elem)
if (!Element.isElement(node)) return node
const elemNode = node as IndentElement
const indent = getStyleValue($elem, 'text-indent')
const indentNumber = parseInt(indent, 10)
if (indent && indentNumber > 0) {
elemNode.indent = indent
}
return elemNode
}
================================================
FILE: packages/basic-modules/src/modules/indent/pre-parse-html.ts
================================================
/**
* @description pre-parse html
* @author wangfupeng
*/
import $, { DOMElement, getStyleValue } from '../../utils/dom'
/**
* pre-prase text-indent 兼容 V4 和 V5 早期格式(都使用 padding-left)
* @param elem elem
*/
function preParse(elem: DOMElement): DOMElement {
const $elem = $(elem)
const paddingLeft = getStyleValue($elem, 'padding-left')
if (/\dem/.test(paddingLeft)) {
// 如 '2em' ,V4 格式
$elem.css('text-indent', '2em')
}
if (/\dpx/.test(paddingLeft)) {
// px 单位
const num = parseInt(paddingLeft, 10)
if (num % 32 === 0) {
// 如 32px 64px ,V5 早期格式
$elem.css('text-indent', '2em')
}
}
return $elem[0]
}
export const preParseHtmlConf = {
selector: 'p,h1,h2,h3,h4,h5',
preParseHtml: preParse,
}
================================================
FILE: packages/basic-modules/src/modules/indent/render-style.tsx
================================================
/**
* @description render indent style
* @author wangfupeng
*/
import { Element, Descendant } from 'slate'
import { jsx, VNode } from 'snabbdom'
import { addVnodeStyle } from '../../utils/vdom'
import { IndentElement } from './custom-types'
/**
* 添加样式
* @param node slate elem
* @param vnode vnode
* @returns vnode
*/
export function renderStyle(node: Descendant, vnode: VNode): VNode {
if (!Element.isElement(node)) return vnode
const { indent } = node as IndentElement // 如 '2em'
let styleVnode: VNode = vnode
if (indent) {
addVnodeStyle(styleVnode, { textIndent: indent })
}
return styleVnode
}
================================================
FILE: packages/basic-modules/src/modules/indent/style-to-html.ts
================================================
/**
* @description textStyle to html
* @author wangfupeng
*/
import { Element, Descendant } from 'slate'
import $, { getOuterHTML } from '../../utils/dom'
import { IndentElement } from './custom-types'
export function styleToHtml(node: Descendant, elemHtml: string): string {
if (!Element.isElement(node)) return elemHtml
const { indent } = node as IndentElement // 如 '2em'
if (!indent) return elemHtml
// 设置样式
const $elem = $(elemHtml)
$elem.css('text-indent', indent)
// 输出 html
return getOuterHTML($elem)
}
================================================
FILE: packages/basic-modules/src/modules/justify/custom-types.ts
================================================
/**
* @description 自定义 element
* @author wangfupeng
*/
import { Text } from 'slate'
//【注意】需要把自定义的 Element 引入到最外层的 custom-types.d.ts
export type JustifyElement = {
type: string
textAlign?: string
children: Text[]
}
================================================
FILE: packages/basic-modules/src/modules/justify/index.ts
================================================
/**
* @description justify module entry
* @author wangfupeng
*/
import { IModuleConf } from '@wangeditor/core'
import { renderStyle } from './render-style'
import { styleToHtml } from './style-to-html'
import { parseStyleHtml } from './parse-style-html'
import {
justifyLeftMenuConf,
justifyRightMenuConf,
justifyCenterMenuConf,
justifyJustifyMenuConf,
} from './menu/index'
const justify: Partial = {
renderStyle,
styleToHtml,
parseStyleHtml,
menus: [justifyLeftMenuConf, justifyRightMenuConf, justifyCenterMenuConf, justifyJustifyMenuConf],
}
export default justify
================================================
FILE: packages/basic-modules/src/modules/justify/menu/BaseMenu.ts
================================================
/**
* @description justify base menu
* @author wangfupeng
*/
import { Editor, Node, Element } from 'slate'
import { IButtonMenu, IDomEditor, DomEditor } from '@wangeditor/core'
abstract class BaseMenu implements IButtonMenu {
abstract readonly title: string
abstract readonly iconSvg: string
readonly tag = 'button'
getValue(editor: IDomEditor): string | boolean {
// 不需要 value
return ''
}
isActive(editor: IDomEditor): boolean {
// 不需要 active
return false
}
/**
* 获取 node 节点
* @param editor editor
*/
protected getMatchNode(editor: IDomEditor): Node | null {
const [nodeEntry] = Editor.nodes(editor, {
match: n => {
const type = DomEditor.getNodeType(n)
// 只可用于 p blockquote header
if (type === 'paragraph') return true
if (type === 'blockquote') return true
if (type.startsWith('header')) return true
return false
},
universal: true,
mode: 'highest', // 匹配最高层级
})
if (nodeEntry == null) return null
return nodeEntry[0]
}
isDisabled(editor: IDomEditor): boolean {
if (editor.selection == null) return true
const selectedElems = DomEditor.getSelectedElems(editor)
const notMatch = selectedElems.some((elem: Node) => {
if (Editor.isVoid(editor, elem) && Editor.isBlock(editor, elem)) return true
const { type } = elem as unknown as Element
if (['pre', 'code'].includes(type)) return true
})
if (notMatch) return true
return false
}
abstract exec(editor: IDomEditor, value: string | boolean): void
}
export default BaseMenu
================================================
FILE: packages/basic-modules/src/modules/justify/menu/JustifyCenterMenu.ts
================================================
/**
* @description justify center menu
* @author wangfupeng
*/
import { Transforms, Element } from 'slate'
import { IDomEditor, t } from '@wangeditor/core'
import BaseMenu from './BaseMenu'
import { JUSTIFY_CENTER_SVG } from '../../../constants/icon-svg'
class JustifyCenterMenu extends BaseMenu {
readonly title = t('justify.center')
readonly iconSvg = JUSTIFY_CENTER_SVG
exec(editor: IDomEditor, value: string | boolean): void {
Transforms.setNodes(
editor,
{
textAlign: 'center',
},
{ match: n => Element.isElement(n) && !editor.isInline(n) } // inline 元素设置text-align 是没作用的
)
}
}
export default JustifyCenterMenu
================================================
FILE: packages/basic-modules/src/modules/justify/menu/JustifyJustifyMenu.ts
================================================
/**
* @description 两端对齐
* @author wangfupeng
*/
import { Transforms, Element } from 'slate'
import { IDomEditor, t } from '@wangeditor/core'
import BaseMenu from './BaseMenu'
import { JUSTIFY_JUSTIFY_SVG } from '../../../constants/icon-svg'
class JustifyJustifyMenu extends BaseMenu {
readonly title = t('justify.justify')
readonly iconSvg = JUSTIFY_JUSTIFY_SVG
exec(editor: IDomEditor, value: string | boolean): void {
Transforms.setNodes(
editor,
{
textAlign: 'justify',
},
{ match: n => Element.isElement(n) && !editor.isInline(n) }
)
}
}
export default JustifyJustifyMenu
================================================
FILE: packages/basic-modules/src/modules/justify/menu/JustifyLeftMenu.ts
================================================
/**
* @description justify left menu
* @author wangfupeng
*/
import { Transforms, Element } from 'slate'
import { IDomEditor, t } from '@wangeditor/core'
import BaseMenu from './BaseMenu'
import { JUSTIFY_LEFT_SVG } from '../../../constants/icon-svg'
class JustifyLeftMenu extends BaseMenu {
readonly title = t('justify.left')
readonly iconSvg = JUSTIFY_LEFT_SVG
exec(editor: IDomEditor, value: string | boolean): void {
Transforms.setNodes(
editor,
{
textAlign: 'left',
},
{ match: n => Element.isElement(n) && !editor.isInline(n) }
)
}
}
export default JustifyLeftMenu
================================================
FILE: packages/basic-modules/src/modules/justify/menu/JustifyRightMenu.ts
================================================
/**
* @description justify right menu
* @author wangfupeng
*/
import { Transforms, Element } from 'slate'
import { IDomEditor, t } from '@wangeditor/core'
import BaseMenu from './BaseMenu'
import { JUSTIFY_RIGHT_SVG } from '../../../constants/icon-svg'
class JustifyRightMenu extends BaseMenu {
readonly title = t('justify.right')
readonly iconSvg = JUSTIFY_RIGHT_SVG
exec(editor: IDomEditor, value: string | boolean): void {
Transforms.setNodes(
editor,
{
textAlign: 'right',
},
{ match: n => Element.isElement(n) && !editor.isInline(n) }
)
}
}
export default JustifyRightMenu
================================================
FILE: packages/basic-modules/src/modules/justify/menu/index.ts
================================================
/**
* @description justify menu entry
* @author wangfupeng
*/
import JustifyLeftMenu from './JustifyLeftMenu'
import JustifyRightMenu from './JustifyRightMenu'
import JustifyCenterMenu from './JustifyCenterMenu'
import JustifyJustifyMenu from './JustifyJustifyMenu'
export const justifyLeftMenuConf = {
key: 'justifyLeft',
factory() {
return new JustifyLeftMenu()
},
}
export const justifyRightMenuConf = {
key: 'justifyRight',
factory() {
return new JustifyRightMenu()
},
}
export const justifyCenterMenuConf = {
key: 'justifyCenter',
factory() {
return new JustifyCenterMenu()
},
}
export const justifyJustifyMenuConf = {
key: 'justifyJustify',
factory() {
return new JustifyJustifyMenu()
},
}
================================================
FILE: packages/basic-modules/src/modules/justify/parse-style-html.ts
================================================
/**
* @description parse style html
* @author wangfupeng
*/
import { Descendant, Element } from 'slate'
import { IDomEditor } from '@wangeditor/core'
import { JustifyElement } from './custom-types'
import $, { DOMElement, getStyleValue } from '../../utils/dom'
export function parseStyleHtml(elem: DOMElement, node: Descendant, editor: IDomEditor): Descendant {
const $elem = $(elem)
if (!Element.isElement(node)) return node
const elemNode = node as JustifyElement
const textAlign = getStyleValue($elem, 'text-align')
if (textAlign) {
elemNode.textAlign = textAlign
}
return elemNode
}
================================================
FILE: packages/basic-modules/src/modules/justify/render-style.tsx
================================================
/**
* @description render justify style
* @author wangfupeng
*/
import { Descendant, Element } from 'slate'
import { jsx, VNode } from 'snabbdom'
import { addVnodeStyle } from '../../utils/vdom'
import { JustifyElement } from './custom-types'
/**
* 添加样式
* @param node slate elem
* @param vnode vnode
* @returns vnode
*/
export function renderStyle(node: Descendant, vnode: VNode): VNode {
if (!Element.isElement(node)) return vnode
const { textAlign } = node as JustifyElement // 如 'left'/'right'/'center' 等
let styleVnode: VNode = vnode
if (textAlign) {
addVnodeStyle(styleVnode, { textAlign })
}
return styleVnode
}
================================================
FILE: packages/basic-modules/src/modules/justify/style-to-html.ts
================================================
/**
* @description textStyle to html
* @author wangfupeng
*/
import { Element, Descendant } from 'slate'
import $, { getOuterHTML } from '../../utils/dom'
import { JustifyElement } from './custom-types'
export function styleToHtml(node: Descendant, elemHtml: string): string {
if (!Element.isElement(node)) return elemHtml
const { textAlign } = node as JustifyElement // 如 'left'/'right'/'center' 等
if (!textAlign) return elemHtml
// 设置样式
const $elem = $(elemHtml)
$elem.css('text-align', textAlign)
// 输出 html
const outerHtml = getOuterHTML($elem)
return outerHtml
}
================================================
FILE: packages/basic-modules/src/modules/line-height/custom-types.ts
================================================
/**
* @description 自定义 element
* @author wangfupeng
*/
import { Text } from 'slate'
//【注意】需要把自定义的 Element 引入到最外层的 custom-types.d.ts
export type LineHeightElement = {
type: string
lineHeight?: string
children: Text[]
}
================================================
FILE: packages/basic-modules/src/modules/line-height/index.ts
================================================
/**
* @description line-height module entry
* @author wangfupeng
*/
import { IModuleConf } from '@wangeditor/core'
import { renderStyle } from './render-style'
import { styleToHtml } from './style-to-html'
import { lineHeightMenuConf } from './menu/index'
import { parseStyleHtml } from './parse-style-html'
const lineHeight: Partial = {
renderStyle,
styleToHtml,
parseStyleHtml,
menus: [lineHeightMenuConf],
}
export default lineHeight
================================================
FILE: packages/basic-modules/src/modules/line-height/menu/LineHeightMenu.ts
================================================
/**
* @description header menu
* @author wangfupeng
*/
import { Editor, Node, Element, Transforms } from 'slate'
import { ISelectMenu, IDomEditor, DomEditor, IOption, t } from '@wangeditor/core'
import { LINE_HEIGHT_SVG } from '../../../constants/icon-svg'
import { LineHeightElement } from '../custom-types'
class LineHeightMenu implements ISelectMenu {
readonly title = t('lineHeight.title')
readonly iconSvg = LINE_HEIGHT_SVG
readonly tag = 'select'
readonly width = 80
getOptions(editor: IDomEditor): IOption[] {
const options: IOption[] = []
// 获取配置,参考 './config.ts'
const { lineHeightList = [] } = editor.getMenuConfig('lineHeight')
// 生成 options
options.push({
text: t('lineHeight.default'),
value: '', // this.getValue(editor) 未找到结果时,会返回 '' ,正好对应到这里
})
lineHeightList.forEach((height: string) => {
options.push({
text: height,
value: height,
})
})
// 设置 selected
const curValue = this.getValue(editor)
options.forEach(opt => {
if (opt.value === curValue) {
opt.selected = true
} else {
delete opt.selected
}
})
return options
}
/**
* 获取匹配的 node 节点
* @param editor editor
*/
private getMatchNode(editor: IDomEditor): Node | null {
const [nodeEntry] = Editor.nodes(editor, {
match: n => {
const type = DomEditor.getNodeType(n)
// line-height 匹配如下类型的 node
if (type.startsWith('header')) return true
if (['paragraph', 'blockquote', 'list-item'].includes(type)) {
return true
}
return false
},
universal: true,
mode: 'highest', // 匹配最高层级
})
if (nodeEntry == null) return null
return nodeEntry[0]
}
isActive(editor: IDomEditor): boolean {
// select menu 会显示 selected value ,用不到 active
return false
}
/**
* 获取 node.lineHeight 的值(如 '1' '1.5'),没有则返回 ''
* @param editor editor
*/
getValue(editor: IDomEditor): string | boolean {
const node = this.getMatchNode(editor)
if (node == null) return ''
if (!Element.isElement(node)) return ''
return (node as LineHeightElement).lineHeight || ''
}
isDisabled(editor: IDomEditor): boolean {
if (editor.selection == null) return true // 禁用
const node = this.getMatchNode(editor)
if (node == null) return true // 未匹配到指定 node ,禁用
return false
}
exec(editor: IDomEditor, value: string | boolean) {
Transforms.setNodes(
editor,
{
lineHeight: value.toString(),
},
{ mode: 'highest' }
)
}
}
export default LineHeightMenu
================================================
FILE: packages/basic-modules/src/modules/line-height/menu/config.ts
================================================
/**
* @description line-height config
* @author wangfupeng
*/
export function genLineHeightConfig() {
return ['1', '1.15', '1.5', '2', '2.5', '3']
}
================================================
FILE: packages/basic-modules/src/modules/line-height/menu/index.ts
================================================
/**
* @description line-height menu entry
* @author wangfupeng
*/
import LineHeightMenu from './LineHeightMenu'
import { genLineHeightConfig } from './config'
export const lineHeightMenuConf = {
key: 'lineHeight',
factory() {
return new LineHeightMenu()
},
// 默认的菜单菜单配置,将存储在 editorConfig.MENU_CONF[key] 中
// 创建编辑器时,可通过 editorConfig.MENU_CONF[key] = {...} 来修改
config: {
lineHeightList: genLineHeightConfig(),
},
}
================================================
FILE: packages/basic-modules/src/modules/line-height/parse-style-html.ts
================================================
/**
* @description parse style html
* @author wangfupeng
*/
import { Descendant, Element } from 'slate'
import { IDomEditor } from '@wangeditor/core'
import { LineHeightElement } from './custom-types'
import $, { DOMElement, getStyleValue } from '../../utils/dom'
export function parseStyleHtml(elem: DOMElement, node: Descendant, editor: IDomEditor): Descendant {
const $elem = $(elem)
if (!Element.isElement(node)) return node
const elemNode = node as LineHeightElement
const { lineHeightList = [] } = editor.getMenuConfig('lineHeight')
const lineHeight = getStyleValue($elem, 'line-height')
if (lineHeight && lineHeightList.includes(lineHeight)) {
elemNode.lineHeight = lineHeight
}
return elemNode
}
================================================
FILE: packages/basic-modules/src/modules/line-height/render-style.tsx
================================================
/**
* @description render line-height style
* @author wangfupeng
*/
import { Element, Descendant } from 'slate'
import { jsx, VNode } from 'snabbdom'
import { addVnodeStyle } from '../../utils/vdom'
import { LineHeightElement } from './custom-types'
/**
* 添加样式
* @param node slate elem
* @param vnode vnode
* @returns vnode
*/
export function renderStyle(node: Descendant, vnode: VNode): VNode {
if (!Element.isElement(node)) return vnode
const { lineHeight } = node as LineHeightElement // 如 '1' '1.5'
let styleVnode: VNode = vnode
if (lineHeight) {
addVnodeStyle(styleVnode, { lineHeight })
}
return styleVnode
}
================================================
FILE: packages/basic-modules/src/modules/line-height/style-to-html.ts
================================================
/**
* @description textStyle to html
* @author wangfupeng
*/
import { Element, Descendant } from 'slate'
import $, { getOuterHTML } from '../../utils/dom'
import { LineHeightElement } from './custom-types'
export function styleToHtml(node: Descendant, elemHtml: string): string {
if (!Element.isElement(node)) return elemHtml
const { lineHeight } = node as LineHeightElement // 如 '1' '1.5'
if (!lineHeight) return elemHtml
// 设置样式
const $elem = $(elemHtml)
$elem.css('line-height', lineHeight)
// 输出 html
return getOuterHTML($elem)
}
================================================
FILE: packages/basic-modules/src/modules/link/custom-types.ts
================================================
/**
* @description 自定义 element
* @author wangfupeng
*/
import { Text } from 'slate'
//【注意】需要把自定义的 Element 引入到最外层的 custom-types.d.ts
export type LinkElement = {
type: 'link'
url: string
target?: string
children: Text[]
}
================================================
FILE: packages/basic-modules/src/modules/link/elem-to-html.ts
================================================
/**
* @description to html
* @author wangfupeng
*/
import { Element } from 'slate'
import { LinkElement } from './custom-types'
function linkToHtml(elem: Element, childrenHtml: string): string {
const { url, target = '_blank' } = elem as LinkElement
return `${childrenHtml}`
}
export const linkToHtmlConf = {
type: 'link',
elemToHtml: linkToHtml,
}
================================================
FILE: packages/basic-modules/src/modules/link/helper.ts
================================================
/**
* @description link helper
* @author wangfupeng
*/
import { Editor, Range, Transforms } from 'slate'
import { IDomEditor, DomEditor } from '@wangeditor/core'
import { LinkElement } from './custom-types'
import { replaceSymbols } from '../../utils/util'
/**
* 校验 link
* @param menuKey menu key
* @param editor editor
* @param text menu text
* @param url menu url
*/
async function check(
menuKey: string,
editor: IDomEditor,
text: string,
url: string
): Promise {
const { checkLink } = editor.getMenuConfig(menuKey)
if (checkLink) {
const res = await checkLink(text, url)
if (typeof res === 'string') {
// 检验未通过,提示信息
editor.alert(res, 'error')
return false
}
if (res == null) {
// 检验未通过,不提示信息
return false
}
}
return true // 校验通过
}
/**
* 转换链接 url
* @param menuKey menu key
* @param editor editor
* @param url url
* @returns parsedUrl
*/
async function parse(menuKey: string, editor: IDomEditor, url: string): Promise {
const { parseLinkUrl } = editor.getMenuConfig(menuKey)
if (parseLinkUrl) {
const newUrl = await parseLinkUrl(url)
return newUrl
}
return url
}
export function isMenuDisabled(editor: IDomEditor): boolean {
if (editor.selection == null) return true
const selectedElems = DomEditor.getSelectedElems(editor)
const notMatch = selectedElems.some(elem => {
const { type } = elem
if (editor.isVoid(elem)) return true
if (['pre', 'code', 'link'].includes(type)) return true
})
if (notMatch) return true // disabled
return false // enable
}
/**
* 生成 link node
* @param url url
* @param text text
*/
function genLinkNode(url: string, text?: string): LinkElement {
const linkNode: LinkElement = {
type: 'link',
url: replaceSymbols(url),
children: text ? [{ text }] : [],
}
return linkNode
}
/**
* 插入 link
* @param editor editor
* @param text text
* @param url url
*/
export async function insertLink(editor: IDomEditor, text: string, url: string) {
if (!url) return
if (!text) text = url // 无 text 则用 url 代替
// 还原选区
editor.restoreSelection()
if (isMenuDisabled(editor)) return
// 校验
const checkRes = await check('insertLink', editor, text, url)
if (!checkRes) return // 校验未通过
// 转换 url
const parsedUrl = await parse('insertLink', editor, url)
// 判断选区是否折叠
const { selection } = editor
if (selection == null) return
const isCollapsed = Range.isCollapsed(selection)
// 执行:插入链接
if (isCollapsed) {
// 链接前后插入空格,方便操作
editor.insertText(' ')
const linkNode = genLinkNode(parsedUrl, text)
Transforms.insertNodes(editor, linkNode)
// https://github.com/wangeditor-team/wangEditor/issues/332
// 不能直接使用 insertText, 会造成添加的空格被添加到链接文本中,参考上面 issue,替换为 insertFragment 方式添加空格
editor.insertFragment([{ text: ' ' }])
} else {
const selectedText = Editor.string(editor, selection) // 选中的文字
if (selectedText !== text) {
// 选中的文字和输入的文字不一样,则删掉文字,插入链接
editor.deleteFragment()
const linkNode = genLinkNode(parsedUrl, text)
Transforms.insertNodes(editor, linkNode)
} else {
// 选中的文字和输入的文字一样,则只包裹链接即可
const linkNode = genLinkNode(parsedUrl)
Transforms.wrapNodes(editor, linkNode, { split: true })
Transforms.collapse(editor, { edge: 'end' })
}
}
}
/**
* 修改 link url
* @param editor editor
* @param text text
* @param url link url
*/
export async function updateLink(editor: IDomEditor, text: string, url: string) {
if (!url) return
// 校验
const checkRes = await check('editLink', editor, text, url)
if (!checkRes) return // 校验未通过
// 转换 url
const parsedUrl = await parse('editLink', editor, url)
// 修改链接
const props: Partial = { url: replaceSymbols(parsedUrl) }
Transforms.setNodes(editor, props, {
match: n => DomEditor.checkNodeType(n, 'link'),
})
}
================================================
FILE: packages/basic-modules/src/modules/link/index.ts
================================================
/**
* @description link entry
* @author wangfupeng
*/
import { IModuleConf } from '@wangeditor/core'
import withLink from './plugin'
import { renderLinkConf } from './render-elem'
import { linkToHtmlConf } from './elem-to-html'
import { parseHtmlConf } from './parse-elem-html'
import {
insertLinkMenuConf,
editLinkMenuConf,
unLinkMenuConf,
viewLinkMenuConf,
} from './menu/index'
const link: Partial = {
renderElems: [renderLinkConf],
elemsToHtml: [linkToHtmlConf],
parseElemsHtml: [parseHtmlConf],
menus: [insertLinkMenuConf, editLinkMenuConf, unLinkMenuConf, viewLinkMenuConf],
editorPlugin: withLink,
}
export default link
================================================
FILE: packages/basic-modules/src/modules/link/menu/EditLink.ts
================================================
/**
* @description update link menu
* @author wangfupeng
*/
import { Node } from 'slate'
import {
IModalMenu,
IDomEditor,
DomEditor,
genModalInputElems,
genModalButtonElems,
t,
} from '@wangeditor/core'
import $, { Dom7Array, DOMElement } from '../../../utils/dom'
import { genRandomStr } from '../../../utils/util'
import { PENCIL_SVG } from '../../../constants/icon-svg'
import { updateLink } from '../helper'
import { LinkElement } from '../custom-types'
/**
* 生成唯一的 DOM ID
*/
function genDomID(): string {
return genRandomStr('w-e-update-link')
}
class EditLinkMenu implements IModalMenu {
readonly title = t('link.edit')
readonly iconSvg = PENCIL_SVG
readonly tag = 'button'
readonly showModal = true // 点击 button 时显示 modal
readonly modalWidth = 300
private $content: Dom7Array | null = null
private urlInputId = genDomID()
private buttonId = genDomID()
private getSelectedLinkElem(editor: IDomEditor): LinkElement | null {
const node = DomEditor.getSelectedNodeByType(editor, 'link')
if (node == null) return null
return node as LinkElement
}
/**
* 获取 node.url
* @param editor editor
*/
getValue(editor: IDomEditor): string | boolean {
const linkElem = this.getSelectedLinkElem(editor)
if (linkElem) {
return linkElem.url || ''
}
return ''
}
isActive(editor: IDomEditor): boolean {
// 无需 active
return false
}
exec(editor: IDomEditor, value: string | boolean) {
// 点击菜单时,弹出 modal 之前,不需要执行其他代码
// 此处空着即可
}
isDisabled(editor: IDomEditor): boolean {
if (editor.selection == null) return true
const linkElem = this.getSelectedLinkElem(editor)
// 未匹配到 link node 则禁用
if (linkElem == null) return true
return false
}
// modal 定位
getModalPositionNode(editor: IDomEditor): Node | null {
return DomEditor.getSelectedNodeByType(editor, 'link')
}
getModalContentElem(editor: IDomEditor): DOMElement {
const { urlInputId, buttonId } = this
// 获取 input button elem
const [urlContainerElem, inputUrlElem] = genModalInputElems(t('link.url'), urlInputId)
const $inputUrl = $(inputUrlElem)
const [buttonContainerElem] = genModalButtonElems(buttonId, t('common.ok'))
if (this.$content == null) {
// 第一次渲染
const $content = $('')
// 绑定事件(第一次渲染时绑定,不要重复绑定)
$content.on('click', 'button', e => {
e.preventDefault()
editor.restoreSelection() // 还原选区
const n = DomEditor.getSelectedNodeByType(editor, 'link')
const text = n ? Node.string(n) : ''
const url = $content.find(`#${urlInputId}`).val()
updateLink(editor, text, url) // 修改链接
editor.hidePanelOrModal() // 隐藏 modal
})
// 记录属性,重要
this.$content = $content
}
const $content = this.$content
$content.empty() // 先清空内容
// append input and button
$content.append(urlContainerElem)
$content.append(buttonContainerElem)
// 设置 input val
const url = this.getValue(editor)
$inputUrl.val(url)
// focus 一个 input(异步,此时 DOM 尚未渲染)
setTimeout(() => {
$inputUrl.focus()
})
return $content[0]
}
}
export default EditLinkMenu
================================================
FILE: packages/basic-modules/src/modules/link/menu/InsertLink.ts
================================================
/**
* @description insert link menu
* @author wangfupeng
*/
import { Editor, Range, Node } from 'slate'
import {
IModalMenu,
IDomEditor,
genModalInputElems,
genModalButtonElems,
t,
} from '@wangeditor/core'
import $, { Dom7Array, DOMElement } from '../../../utils/dom'
import { genRandomStr } from '../../../utils/util'
import { LINK_SVG } from '../../../constants/icon-svg'
import { isMenuDisabled, insertLink } from '../helper'
/**
* 生成唯一的 DOM ID
*/
function genDomID(): string {
return genRandomStr('w-e-insert-link')
}
class InsertLinkMenu implements IModalMenu {
readonly title = t('link.insert')
readonly iconSvg = LINK_SVG
readonly tag = 'button'
readonly showModal = true // 点击 button 时显示 modal
readonly modalWidth = 300
private $content: Dom7Array | null = null
private readonly textInputId = genDomID()
private readonly urlInputId = genDomID()
private readonly buttonId = genDomID()
getValue(editor: IDomEditor): string | boolean {
// 插入菜单,不需要 value
return ''
}
isActive(editor: IDomEditor): boolean {
// 任何时候,都不用激活 menu
return false
}
exec(editor: IDomEditor, value: string | boolean) {
// 点击菜单时,弹出 modal 之前,不需要执行其他代码
// 此处空着即可
}
isDisabled(editor: IDomEditor): boolean {
return isMenuDisabled(editor)
}
getModalPositionNode(editor: IDomEditor): Node | null {
return null // modal 依据选区定位
}
getModalContentElem(editor: IDomEditor): DOMElement {
const { selection } = editor
const { textInputId, urlInputId, buttonId } = this
// 获取 input button elem
const [textContainerElem, inputTextElem] = genModalInputElems(t('link.text'), textInputId)
const $inputText = $(inputTextElem)
const [urlContainerElem, inputUrlElem] = genModalInputElems(t('link.url'), urlInputId)
const $inputUrl = $(inputUrlElem)
const [buttonContainerElem] = genModalButtonElems(buttonId, t('common.ok'))
if (this.$content == null) {
// 第一次渲染
const $content = $('')
// 绑定事件(第一次渲染时绑定,不要重复绑定)
$content.on('click', `#${buttonId}`, e => {
e.preventDefault()
const text = $content.find(`#${textInputId}`).val()
const url = $content.find(`#${urlInputId}`).val()
insertLink(editor, text, url) // 插入链接
editor.hidePanelOrModal() // 隐藏 modal
})
// 记录属性,重要
this.$content = $content
}
const $content = this.$content
$content.empty() // 先清空内容
// append inputs and button
$content.append(textContainerElem)
$content.append(urlContainerElem)
$content.append(buttonContainerElem)
// 设置 input val
if (selection == null || Range.isCollapsed(selection)) {
// 选区无内容
$inputText.val('')
} else {
// 选区有内容
const selectionText = Editor.string(editor, selection)
$inputText.val(selectionText)
}
$inputUrl.val('')
// focus 一个 input(异步,此时 DOM 尚未渲染)
setTimeout(() => {
$inputText.focus()
})
return $content[0]
}
}
export default InsertLinkMenu
================================================
FILE: packages/basic-modules/src/modules/link/menu/UnLink.ts
================================================
/**
* @description unlink menu
* @author wangfupeng
*/
import { Transforms } from 'slate'
import { IButtonMenu, IDomEditor, DomEditor, t } from '@wangeditor/core'
import { UN_LINK_SVG } from '../../../constants/icon-svg'
class UnLink implements IButtonMenu {
readonly title = t('link.unLink')
readonly iconSvg = UN_LINK_SVG
readonly tag = 'button'
getValue(editor: IDomEditor): string | boolean {
// 无需获取 val
return ''
}
isActive(editor: IDomEditor): boolean {
// 无需 active
return false
}
isDisabled(editor: IDomEditor): boolean {
if (editor.selection == null) return true
const linkNode = DomEditor.getSelectedNodeByType(editor, 'link')
if (linkNode == null) {
// 选区未处于 link node ,则禁用
return true
}
return false
}
exec(editor: IDomEditor, value: string | boolean) {
if (this.isDisabled(editor)) return
// 取消链接
Transforms.unwrapNodes(editor, {
match: n => DomEditor.checkNodeType(n, 'link'),
})
}
}
export default UnLink
================================================
FILE: packages/basic-modules/src/modules/link/menu/ViewLink.ts
================================================
/**
* @description view link menu
* @author wangfupeng
*/
import { IButtonMenu, IDomEditor, DomEditor, t } from '@wangeditor/core'
import { EXTERNAL_SVG } from '../../../constants/icon-svg'
import { LinkElement } from '../custom-types'
class ViewLink implements IButtonMenu {
readonly title = t('link.view')
readonly iconSvg = EXTERNAL_SVG
readonly tag = 'button'
private getSelectedLinkElem(editor: IDomEditor): LinkElement | null {
const node = DomEditor.getSelectedNodeByType(editor, 'link')
if (node == null) return null
return node as LinkElement
}
getValue(editor: IDomEditor): string | boolean {
const linkElem = this.getSelectedLinkElem(editor)
if (linkElem) {
return linkElem.url || ''
}
return ''
}
isActive(editor: IDomEditor): boolean {
// 无需 active
return false
}
isDisabled(editor: IDomEditor): boolean {
if (editor.selection == null) return true
const linkElem = this.getSelectedLinkElem(editor)
if (linkElem == null) {
// 选区未处于 link node ,则禁用
return true
}
return false
}
exec(editor: IDomEditor, value: string | boolean) {
if (this.isDisabled(editor)) return
if (!value || typeof value !== 'string') {
throw new Error(`View link failed, link url is '${value}'`)
}
// 查看链接
window.open(value, '_blank')
}
}
export default ViewLink
================================================
FILE: packages/basic-modules/src/modules/link/menu/config.ts
================================================
/**
* @description link menu config
* @author wangfupeng
*/
export function genLinkMenuConfig() {
return {
/**
* 检查链接,支持 async fn
* @param text link text
* @param url link url
*/
checkLink(text: string, url: string): boolean | string | undefined {
// 1. 返回 true ,说明检查通过
// 2. 返回一个字符串,说明检查未通过,编辑器会阻止插入。会 alert 出错误信息(即返回的字符串)
// 3. 返回 undefined(即没有任何返回),说明检查未通过,编辑器会阻止插入
return true
},
/**
* parse link url
* @param url url
* @returns newUrl
*/
parseLinkUrl(url: string): string {
return url
},
}
}
================================================
FILE: packages/basic-modules/src/modules/link/menu/index.ts
================================================
/**
* @description link menu entry
* @author wangfupeng
*/
import InsertLink from './InsertLink'
import EditLink from './EditLink'
import UnLink from './UnLink'
import ViewLink from './ViewLink'
import { genLinkMenuConfig } from './config'
const config = genLinkMenuConfig() // menu config
const insertLinkMenuConf = {
key: 'insertLink',
factory() {
return new InsertLink()
},
// 默认的菜单菜单配置,将存储在 editorConfig.MENU_CONF[key] 中
// 创建编辑器时,可通过 editorConfig.MENU_CONF[key] = {...} 来修改
config,
}
const editLinkMenuConf = {
key: 'editLink',
factory() {
return new EditLink()
},
config,
}
const unLinkMenuConf = {
key: 'unLink',
factory() {
return new UnLink()
},
}
const viewLinkMenuConf = {
key: 'viewLink',
factory() {
return new ViewLink()
},
}
export { insertLinkMenuConf, editLinkMenuConf, unLinkMenuConf, viewLinkMenuConf }
================================================
FILE: packages/basic-modules/src/modules/link/parse-elem-html.ts
================================================
/**
* @description parse html
* @author wangfupeng
*/
import { Descendant, Text } from 'slate'
import { IDomEditor } from '@wangeditor/core'
import { LinkElement } from './custom-types'
import $, { DOMElement } from '../../utils/dom'
function parseHtml(elem: DOMElement, children: Descendant[], editor: IDomEditor): LinkElement {
const $elem = $(elem)
children = children.filter(child => {
if (Text.isText(child)) return true
if (editor.isInline(child)) return true
return false
})
// 无 children ,则用纯文本
if (children.length === 0) {
children = [{ text: $elem.text().replace(/\s+/gm, ' ') }]
}
return {
type: 'link',
url: $elem.attr('href') || '',
target: $elem.attr('target') || '',
// @ts-ignore
children,
}
}
export const parseHtmlConf = {
selector: 'a:not([data-w-e-type])', // data-w-e-type 属性,留给自定义元素,保证扩展性
parseElemHtml: parseHtml,
}
================================================
FILE: packages/basic-modules/src/modules/link/plugin.ts
================================================
/**
* @description editor 插件,重写 editor API
* @author wangfupeng
*/
import { Editor, Node, Transforms } from 'slate'
import { DomEditor, IDomEditor } from '@wangeditor/core'
import isUrl from 'is-url'
import { isMenuDisabled, insertLink } from './helper'
function withLink(editor: T): T {
const { isInline, insertData, normalizeNode, insertNode, insertText } = editor
const newEditor = editor
// 重写 isInline
newEditor.isInline = elem => {
const { type } = elem
if (type === 'link') {
return true
}
return isInline(elem)
}
// 重写 insertData ,粘贴插入链接
newEditor.insertData = (data: DataTransfer) => {
const text = data.getData('text/plain')
if (!isUrl(text)) {
// 非链接
insertData(data)
return
}
// 插入链接
if (isMenuDisabled(newEditor)) return // disabled
const { selection } = newEditor
if (selection == null) return
const selectedText = Editor.string(newEditor, selection) // 获取选中的文字
insertLink(newEditor, selectedText, text)
}
newEditor.normalizeNode = ([node, path]) => {
const type = DomEditor.getNodeType(node)
if (type !== 'link') {
// 未命中 link ,执行默认的 normalizeNode
return normalizeNode([node, path])
}
// 如果链接内容为空,则删除
const str = Node.string(node)
if (str === '') {
return Transforms.removeNodes(newEditor, { at: path })
}
return normalizeNode([node, path])
}
// 返回 editor ,重要!
return newEditor
}
export default withLink
================================================
FILE: packages/basic-modules/src/modules/link/render-elem.tsx
================================================
/**
* @description render link elem
* @author wangfupeng
*/
import { Element as SlateElement } from 'slate'
import { jsx, VNode } from 'snabbdom'
import { IDomEditor } from '@wangeditor/core'
import { LinkElement } from './custom-types'
/**
* render link elem
* @param elemNode slate elem
* @param children children
* @param editor editor
* @returns vnode
*/
function renderLink(elemNode: SlateElement, children: VNode[] | null, editor: IDomEditor): VNode {
const { url, target = '_blank' } = elemNode as LinkElement
const vnode = (
{children}
)
return vnode
}
const renderLinkConf = {
type: 'link', // 和 elemNode.type 一致
renderElem: renderLink,
}
export { renderLinkConf }
================================================
FILE: packages/basic-modules/src/modules/paragraph/custom-types.ts
================================================
/**
* @description 自定义 element
* @author wangfupeng
*/
import { Text } from 'slate'
//【注意】需要把自定义的 Element 引入到最外层的 custom-types.d.ts
export type ParagraphElement = {
type: 'paragraph'
children: Text[]
}
================================================
FILE: packages/basic-modules/src/modules/paragraph/elem-to-html.ts
================================================
/**
* @description to html
* @author wangfupeng
*/
import { Element } from 'slate'
function pToHtml(elem: Element, childrenHtml: string): string {
if (childrenHtml === '') {
return '
'
}
return `${childrenHtml}
`
}
export const pToHtmlConf = {
type: 'paragraph',
elemToHtml: pToHtml,
}
================================================
FILE: packages/basic-modules/src/modules/paragraph/index.ts
================================================
/**
* @description paragraph entry
* @author wangfupeng
*/
import { IModuleConf } from '@wangeditor/core'
import { renderParagraphConf } from './render-elem'
import { pToHtmlConf } from './elem-to-html'
import { parseParagraphHtmlConf } from './parse-elem-html'
import withParagraph from './plugin'
const p: Partial = {
renderElems: [renderParagraphConf],
elemsToHtml: [pToHtmlConf],
parseElemsHtml: [parseParagraphHtmlConf],
editorPlugin: withParagraph,
}
export default p
================================================
FILE: packages/basic-modules/src/modules/paragraph/parse-elem-html.ts
================================================
/**
* @description parse html
* @author wangfupeng
*/
import { Descendant, Text } from 'slate'
import { IDomEditor } from '@wangeditor/core'
import { ParagraphElement } from './custom-types'
import $, { DOMElement } from '../../utils/dom'
function parseParagraphHtml(
elem: DOMElement,
children: Descendant[],
editor: IDomEditor
): ParagraphElement {
const $elem = $(elem)
children = children.filter(child => {
if (Text.isText(child)) return true
if (editor.isInline(child)) return true
return false
})
// 无 children ,则用纯文本
if (children.length === 0) {
children = [{ text: $elem.text().replace(/\s+/gm, ' ') }]
}
return {
type: 'paragraph',
// @ts-ignore
children,
}
}
export const parseParagraphHtmlConf = {
selector: 'p:not([data-w-e-type])', // data-w-e-type 属性,留给自定义元素,保证扩展性
parseElemHtml: parseParagraphHtml,
}
================================================
FILE: packages/basic-modules/src/modules/paragraph/plugin.ts
================================================
/**
* @description editor 插件,重写 editor API
* @author wangfupeng
*/
import {
Editor,
Element as SlateElement,
Transforms,
Node as SlateNode,
Text as SlateText,
} from 'slate'
import { IDomEditor } from '@wangeditor/core'
function deleteHandler(newEditor: IDomEditor): boolean {
const [nodeEntry] = Editor.nodes(newEditor, {
match: n => newEditor.children[0] === n, // editor 第一个节点
mode: 'highest', // 最高层级
})
if (nodeEntry == null) return false
const n = nodeEntry[0]
if (!SlateElement.isElement(n)) return false
if (n.type === 'paragraph') return false // 命中了 paragraph ,则不再继续判断
if (SlateNode.string(n) !== '') return false // 未删除全部内容,则不再继续判断
const { children = [] } = n
if (!SlateText.isText(children[0])) return false // n.children 不是 text (如 table),则不再继续判断
// 至此,就命中了一个(非 paragraph)+(children 都是 text)+(内容为空)的顶级 node ,如 header blockQuote 等
// 然后,将其却换为 paragraph
Transforms.setNodes(newEditor, {
type: 'paragraph',
})
return true
}
function withParagraph(editor: T): T {
const { deleteBackward, deleteForward, insertText, insertBreak } = editor
const newEditor = editor
// 删除非 p 的文本 elem(如 header blockQuote 等),删除没有内容时,切换为 p
newEditor.deleteBackward = unit => {
const res = deleteHandler(newEditor)
if (res) return // 命中结果,则 return
// 执行默认的删除
deleteBackward(unit)
}
newEditor.deleteForward = unit => {
const res = deleteHandler(newEditor)
if (res) return // 命中结果,则 return
// 执行默认的删除
deleteForward(unit)
}
// 返回 editor ,重要!
return newEditor
}
export default withParagraph
================================================
FILE: packages/basic-modules/src/modules/paragraph/render-elem.tsx
================================================
/**
* @description render paragraph elem
* @author wangfupeng
*/
import { Element as SlateElement } from 'slate'
import { jsx, VNode } from 'snabbdom'
import { IDomEditor } from '@wangeditor/core'
/**
* render paragraph elem
* @param elemNode slate elem
* @param children children
* @param editor editor
* @returns vnode
*/
function renderParagraph(
elemNode: SlateElement,
children: VNode[] | null,
editor: IDomEditor
): VNode {
const vnode = {children}
return vnode
}
export const renderParagraphConf = {
type: 'paragraph',
renderElem: renderParagraph,
}
================================================
FILE: packages/basic-modules/src/modules/text-style/custom-types.ts
================================================
/**
* @description 自定义 element
* @author wangfupeng
*/
//【注意】需要把自定义的 Text 引入到最外层的 custom-types.d.ts
export type StyledText = {
text: string
bold?: boolean
code?: boolean
italic?: boolean
through?: boolean
underline?: boolean
sup?: boolean
sub?: boolean
}
================================================
FILE: packages/basic-modules/src/modules/text-style/helper.ts
================================================
/**
* @description helper
* @author wangfupeng
*/
import { Editor, Node } from 'slate'
import { IDomEditor, DomEditor } from '@wangeditor/core'
export function isMenuDisabled(editor: IDomEditor, mark?: string): boolean {
if (editor.selection == null) return true
const [match] = Editor.nodes(editor, {
match: n => {
const type = DomEditor.getNodeType(n)
if (type === 'pre') return true // 代码块
if (Editor.isVoid(editor, n)) return true // void node
return false
},
universal: true,
})
// 命中,则禁用
if (match) return true
return false
}
export function removeMarks(editor: IDomEditor, textNode: Node) {
// 遍历 text node 属性,清除样式
const keys = Object.keys(textNode as object)
keys.forEach(key => {
if (key === 'text') {
// 保留 text 属性,text node 必须的
return
}
// 其他属性,全部清除
Editor.removeMark(editor, key)
})
}
================================================
FILE: packages/basic-modules/src/modules/text-style/index.ts
================================================
/**
* @description text style entry
* @author wangfupeng
*/
import { IModuleConf } from '@wangeditor/core'
import { renderStyle } from './render-style'
import { styleToHtml } from './style-to-html'
import { parseStyleHtml } from './parse-style-html'
import {
boldMenuConf,
underlineMenuConf,
italicMenuConf,
throughMenuConf,
codeMenuConf,
subMenuConf,
supMenuConf,
clearStyleMenuConf,
} from './menu/index'
const textStyle: Partial = {
renderStyle,
menus: [
boldMenuConf,
underlineMenuConf,
italicMenuConf,
throughMenuConf,
codeMenuConf,
subMenuConf,
supMenuConf,
clearStyleMenuConf,
],
styleToHtml,
parseStyleHtml,
}
export default textStyle
================================================
FILE: packages/basic-modules/src/modules/text-style/menu/BaseMenu.ts
================================================
/**
* @description simply style base menu
* @author wangfupeng
*/
import { Editor } from 'slate'
import { IButtonMenu, IDomEditor } from '@wangeditor/core'
import { isMenuDisabled } from '../helper'
abstract class BaseMenu implements IButtonMenu {
abstract readonly mark: string
protected readonly marksNeedToRemove: string[] = [] // 增加 mark 的同时,需要移除哪些 mark (互斥,不能共存的)
abstract readonly title: string
abstract readonly iconSvg: string
abstract readonly hotkey: string
readonly tag = 'button'
/**
* 获取:是否有 mark
* @param editor editor
*/
getValue(editor: IDomEditor): string | boolean {
const mark = this.mark
const curMarks = Editor.marks(editor)
// 当 curMarks 存在时,说明用户手动设置,以 curMarks 为准
if (curMarks) {
return curMarks[mark]
} else {
const [match] = Editor.nodes(editor, {
// @ts-ignore
match: n => n[mark] === true,
})
return !!match
}
}
isActive(editor: IDomEditor): boolean {
const isMark = this.getValue(editor)
return !!isMark
}
isDisabled(editor: IDomEditor): boolean {
return isMenuDisabled(editor, this.mark)
}
/**
* 执行命令
* @param editor editor
* @param value 是否有 mark
*/
exec(editor: IDomEditor, value: string | boolean) {
const { mark, marksNeedToRemove } = this
if (value) {
// 已,则取消
editor.removeMark(mark)
} else {
// 没有,则执行
editor.addMark(mark, true)
// 移除互斥、不能共存的 marks
if (marksNeedToRemove) {
marksNeedToRemove.forEach(m => editor.removeMark(m))
}
}
}
}
export default BaseMenu
================================================
FILE: packages/basic-modules/src/modules/text-style/menu/BoldMenu.ts
================================================
/**
* @description bold menu
* @author wangfupeng
*/
import { t } from '@wangeditor/core'
import BaseMenu from './BaseMenu'
import { BOLD_SVG } from '../../../constants/icon-svg'
class BoldMenu extends BaseMenu {
readonly mark = 'bold'
readonly title = t('textStyle.bold')
readonly iconSvg = BOLD_SVG
readonly hotkey = 'mod+b'
}
export default BoldMenu
================================================
FILE: packages/basic-modules/src/modules/text-style/menu/ClearStyleMenu.ts
================================================
/**
* @description clear style menu
* @author wangfupeng
*/
import { Editor, Text } from 'slate'
import { IButtonMenu, IDomEditor, t } from '@wangeditor/core'
import { ERASER_SVG } from '../../../constants/icon-svg'
import { isMenuDisabled, removeMarks } from '../helper'
class ClearStyleMenu implements IButtonMenu {
readonly title = t('textStyle.clear')
readonly iconSvg = ERASER_SVG
readonly tag = 'button'
getValue(editor: IDomEditor): string | boolean {
return ''
}
isActive(editor: IDomEditor): boolean {
return false
}
isDisabled(editor: IDomEditor): boolean {
return isMenuDisabled(editor)
}
/**
* 执行命令
* @param editor editor
* @param value 是否有 mark
*/
exec(editor: IDomEditor, value: string | boolean) {
// 获取所有 text node
const nodeEntries = Editor.nodes(editor, {
match: n => Text.isText(n),
universal: true,
})
for (const nodeEntry of nodeEntries) {
// 单个 text node
const n = nodeEntry[0]
removeMarks(editor, n)
}
}
}
export default ClearStyleMenu
================================================
FILE: packages/basic-modules/src/modules/text-style/menu/CodeMenu.ts
================================================
/**
* @description code menu
* @author wangfupeng
*/
import { t } from '@wangeditor/core'
import BaseMenu from './BaseMenu'
import { CODE_SVG } from '../../../constants/icon-svg'
class CodeMenu extends BaseMenu {
readonly mark = 'code'
readonly title = t('textStyle.code')
readonly iconSvg = CODE_SVG
readonly hotkey = 'mod+e'
}
export default CodeMenu
================================================
FILE: packages/basic-modules/src/modules/text-style/menu/ItalicMenu.ts
================================================
/**
* @description italic menu
* @author wangfupeng
*/
import { t } from '@wangeditor/core'
import BaseMenu from './BaseMenu'
import { ITALIC_SVG } from '../../../constants/icon-svg'
class ItalicMenu extends BaseMenu {
readonly mark = 'italic'
readonly title = t('textStyle.italic')
readonly iconSvg = ITALIC_SVG
readonly hotkey = 'mod+i'
}
export default ItalicMenu
================================================
FILE: packages/basic-modules/src/modules/text-style/menu/SubMenu.ts
================================================
/**
* @description sub menu
* @author wangfupeng
*/
import { t } from '@wangeditor/core'
import BaseMenu from './BaseMenu'
import { SUB_SVG } from '../../../constants/icon-svg'
class SubMenu extends BaseMenu {
readonly mark = 'sub'
readonly marksNeedToRemove = ['sup'] // sub 和 sup 不能共存
readonly title = t('textStyle.sub')
readonly iconSvg = SUB_SVG
readonly hotkey = ''
}
export default SubMenu
================================================
FILE: packages/basic-modules/src/modules/text-style/menu/SupMenu.ts
================================================
/**
* @description sup menu
* @author wangfupeng
*/
import { t } from '@wangeditor/core'
import BaseMenu from './BaseMenu'
import { SUP_SVG } from '../../../constants/icon-svg'
class SupMenu extends BaseMenu {
readonly mark = 'sup'
readonly marksNeedToRemove = ['sub'] // sup 和 sub 不能共存
readonly title = t('textStyle.sup')
readonly iconSvg = SUP_SVG
readonly hotkey = ''
}
export default SupMenu
================================================
FILE: packages/basic-modules/src/modules/text-style/menu/ThroughMenu.ts
================================================
/**
* @description through menu
* @author wangfupeng
*/
import { t } from '@wangeditor/core'
import BaseMenu from './BaseMenu'
import { THROUGH_SVG } from '../../../constants/icon-svg'
class ThroughMenu extends BaseMenu {
readonly mark = 'through'
readonly title = t('textStyle.through')
readonly iconSvg = THROUGH_SVG
readonly hotkey = 'mod+shift+x'
}
export default ThroughMenu
================================================
FILE: packages/basic-modules/src/modules/text-style/menu/UnderlineMenu.ts
================================================
/**
* @description underline menu
* @author wangfupeng
*/
import { t } from '@wangeditor/core'
import BaseMenu from './BaseMenu'
import { UNDER_LINE_SVG } from '../../../constants/icon-svg'
class UnderlineMenu extends BaseMenu {
readonly mark = 'underline'
readonly title = t('textStyle.underline')
readonly iconSvg = UNDER_LINE_SVG
readonly hotkey = 'mod+u'
}
export default UnderlineMenu
================================================
FILE: packages/basic-modules/src/modules/text-style/menu/index.ts
================================================
/**
* @description menu entry
* @author wangfupeng
*/
import BoldMenu from './BoldMenu'
import CodeMenu from './CodeMenu'
import ItalicMenu from './ItalicMenu'
import ThroughMenu from './ThroughMenu'
import UnderlineMenu from './UnderlineMenu'
import SubMenu from './SubMenu'
import SupMenu from './SupMenu'
import ClearStyleMenu from './ClearStyleMenu'
export const boldMenuConf = {
key: 'bold',
factory() {
return new BoldMenu()
},
}
export const codeMenuConf = {
key: 'code',
factory() {
return new CodeMenu()
},
}
export const italicMenuConf = {
key: 'italic',
factory() {
return new ItalicMenu()
},
}
export const throughMenuConf = {
key: 'through',
factory() {
return new ThroughMenu()
},
}
export const underlineMenuConf = {
key: 'underline',
factory() {
return new UnderlineMenu()
},
}
export const supMenuConf = {
key: 'sup',
factory() {
return new SupMenu()
},
}
export const subMenuConf = {
key: 'sub',
factory() {
return new SubMenu()
},
}
export const clearStyleMenuConf = {
key: 'clearStyle',
factory() {
return new ClearStyleMenu()
},
}
================================================
FILE: packages/basic-modules/src/modules/text-style/parse-style-html.ts
================================================
/**
* @description parse style html
* @author wangfupeng
*/
import { Descendant, Text } from 'slate'
import { IDomEditor } from '@wangeditor/core'
import { StyledText } from './custom-types'
import $, { Dom7Array, DOMElement } from '../../utils/dom'
/**
* $text 是否匹配 tags
* @param $text $text
* @param selector selector 如 'b,strong' 或 'sub'
*/
function isMatch($text: Dom7Array, selector: string): boolean {
if ($text.length === 0) return false
if ($text[0].matches(selector)) return true
if ($text.find(selector).length > 0) return true
return false
}
export function parseStyleHtml(
textElem: DOMElement,
node: Descendant,
editor: IDomEditor
): Descendant {
const $text = $(textElem)
if (!Text.isText(node)) return node
const textNode = node as StyledText
// bold
if (isMatch($text, 'b,strong')) {
textNode.bold = true
}
// italic
if (isMatch($text, 'i,em')) {
textNode.italic = true
}
// underline
if (isMatch($text, 'u')) {
textNode.underline = true
}
// through
if (isMatch($text, 's,strike')) {
textNode.through = true
}
// sub
if (isMatch($text, 'sub')) {
textNode.sub = true
}
// sup
if (isMatch($text, 'sup')) {
textNode.sup = true
}
// code
if (isMatch($text, 'code')) {
textNode.code = true
}
return textNode
}
================================================
FILE: packages/basic-modules/src/modules/text-style/render-style.tsx
================================================
/**
* @description render text style
* @author wangfupeng
*/
import { Descendant } from 'slate'
import { jsx, VNode } from 'snabbdom'
import { StyledText } from './custom-types'
/**
* 添加样式
* @param node slate text
* @param vnode vnode
* @returns vnode
*/
export function renderStyle(node: Descendant, vnode: VNode): VNode {
const { bold, italic, underline, code, through, sub, sup } = node as StyledText
let styleVnode: VNode = vnode
// color bgColor 在另外的菜单
if (bold) {
styleVnode = {styleVnode}
}
if (code) {
styleVnode = {styleVnode}
}
if (italic) {
styleVnode = {styleVnode}
}
if (underline) {
styleVnode = {styleVnode}
}
if (through) {
styleVnode = {styleVnode}
}
if (sub) {
styleVnode = {styleVnode}
}
if (sup) {
styleVnode = {styleVnode}
}
return styleVnode
}
================================================
FILE: packages/basic-modules/src/modules/text-style/style-to-html.ts
================================================
/**
* @description text to html
* @author wangfupeng
*/
import { Text, Descendant } from 'slate'
import { StyledText } from './custom-types'
import $, { getOuterHTML, getTagName, isPlainText } from '../../utils/dom'
//【注意】color bgColor fontSize fontFamily 在另外的菜单
/**
* 生成加了样式的 text html
* @param textNode textNode
* @param html text html
*/
function genStyledHtml(textNode: Descendant, html: string): string {
let styledHtml = html
const { bold, italic, underline, code, through, sub, sup } = textNode as StyledText
if (bold) styledHtml = `${styledHtml}`
if (code) styledHtml = `${styledHtml}`
if (italic) styledHtml = `${styledHtml}`
if (underline) styledHtml = `${styledHtml}`
if (through) styledHtml = `${styledHtml}`
if (sub) styledHtml = `${styledHtml}`
if (sup) styledHtml = `${styledHtml}`
return styledHtml
}
/**
* style to html
* @param textNode slate text node
* @param textHtml text html
* @returns styled html
*/
export function styleToHtml(textNode: Descendant, textHtml: string): string {
if (!Text.isText(textNode)) return textHtml
if (isPlainText(textHtml)) {
// textHtml 是纯文本,而不是 html tag
return genStyledHtml(textNode, textHtml)
}
// textHtml 是 html tag
const $text = $(textHtml)
const tagName = getTagName($text)
if (tagName === 'br') {
return genStyledHtml(textNode, '
')
}
let innerHtml = $text.html()
innerHtml = genStyledHtml(textNode, innerHtml)
$text.html(innerHtml)
return getOuterHTML($text)
}
================================================
FILE: packages/basic-modules/src/modules/todo/custom-types.ts
================================================
/**
* @description 自定义 element
* @author wangfupeng
*/
import { Text } from 'slate'
//【注意】需要把自定义的 Element 引入到最外层的 custom-types.d.ts
export type TodoElement = {
type: 'todo'
checked: boolean
children: Text[]
}
================================================
FILE: packages/basic-modules/src/modules/todo/elem-to-html.ts
================================================
/**
* @description to html
* @author wangfupeng
*/
import { Element } from 'slate'
import { TodoElement } from './custom-types'
function todoToHtml(elem: Element, childrenHtml: string): string {
const { checked } = elem as TodoElement
const checkedAttr = checked ? 'checked' : ''
return `${childrenHtml}`
}
export const todoToHtmlConf = {
type: 'todo',
elemToHtml: todoToHtml,
}
================================================
FILE: packages/basic-modules/src/modules/todo/index.ts
================================================
/**
* @description todo entry
* @author wangfupeng
*/
import { IModuleConf } from '@wangeditor/core'
import { renderTodoConf } from './render-elem'
import withTodo from './plugin'
import { todoMenuConf } from './menu/index'
import { todoToHtmlConf } from './elem-to-html'
import { parseHtmlConf } from './parse-elem-html'
import { preParseHtmlConf } from './pre-parse-html'
const todo: Partial = {
renderElems: [renderTodoConf],
elemsToHtml: [todoToHtmlConf],
preParseHtml: [preParseHtmlConf],
parseElemsHtml: [parseHtmlConf],
menus: [todoMenuConf],
editorPlugin: withTodo,
}
export default todo
================================================
FILE: packages/basic-modules/src/modules/todo/menu/Todo.ts
================================================
/**
* @description Todo menu
* @author wangfupeng
*/
import { Editor, Element, Transforms } from 'slate'
import { IButtonMenu, IDomEditor, DomEditor, t } from '@wangeditor/core'
import { CHECK_BOX_SVG } from '../../../constants/icon-svg'
class TodoMenu implements IButtonMenu {
readonly title = t('todo.todo')
readonly iconSvg = CHECK_BOX_SVG
readonly tag = 'button'
getValue(editor: IDomEditor): string | boolean {
// 无需获取 val
return ''
}
isActive(editor: IDomEditor): boolean {
return !!DomEditor.getSelectedNodeByType(editor, 'todo')
}
isDisabled(editor: IDomEditor): boolean {
if (editor.selection == null) return true
const selectedElems = DomEditor.getSelectedElems(editor)
const notMatch = selectedElems.some((elem: Element) => {
if (Editor.isVoid(editor, elem) && Editor.isBlock(editor, elem)) return true
const { type } = elem as Element
if (['pre', 'table', 'list-item'].includes(type)) return true
})
if (notMatch) return true
return false
}
exec(editor: IDomEditor, value: string | boolean) {
const active = this.isActive(editor)
Transforms.setNodes(editor, { type: active ? 'paragraph' : 'todo' })
}
}
export default TodoMenu
================================================
FILE: packages/basic-modules/src/modules/todo/menu/index.ts
================================================
/**
* @description todo menu entry
* @author wangfupeng
*/
import TodoMenu from './Todo'
export const todoMenuConf = {
key: 'todo',
factory() {
return new TodoMenu()
},
}
================================================
FILE: packages/basic-modules/src/modules/todo/parse-elem-html.ts
================================================
/**
* @description parse html
* @author wangfupeng
*/
import { Descendant, Text } from 'slate'
import { IDomEditor } from '@wangeditor/core'
import { TodoElement } from './custom-types'
import $, { DOMElement } from '../../utils/dom'
function parseHtml(elem: DOMElement, children: Descendant[], editor: IDomEditor): TodoElement {
const $elem = $(elem)
children = children.filter(child => {
if (Text.isText(child)) return true
if (editor.isInline(child)) return true
return false
})
// 无 children ,则用纯文本
if (children.length === 0) {
children = [{ text: $elem.text().replace(/\s+/gm, ' ') }]
}
// 获取 checked
let checked = false
const $input = $elem.find('input[type="checkbox"]')
if ($input.attr('checked') != null) {
checked = true
}
return {
type: 'todo',
checked,
// @ts-ignore
children,
}
}
export const parseHtmlConf = {
selector: 'div[data-w-e-type="todo"]',
parseElemHtml: parseHtml,
}
================================================
FILE: packages/basic-modules/src/modules/todo/plugin.ts
================================================
/**
* @description editor 插件,重写 editor API
* @author wangfupeng
*/
import { Node, Transforms, Range } from 'slate'
import { DomEditor, IDomEditor } from '@wangeditor/core'
function withTodo(editor: T): T {
const { deleteBackward } = editor
const newEditor = editor
/**
* 删除 todo 无内容时,变为 paragraph
*/
newEditor.deleteBackward = unit => {
const { selection } = editor
if (selection && Range.isCollapsed(selection)) {
// 获取选中的 todo
const selectedTodo = DomEditor.getSelectedNodeByType(editor, 'todo')
if (selectedTodo) {
if (Node.string(selectedTodo).length === 0) {
// 当前 todo 已经没有文字,则转换为 paragraph
Transforms.setNodes(editor, { type: 'paragraph' }, { mode: 'highest' })
return
}
}
}
deleteBackward(unit)
}
return newEditor
}
export default withTodo
================================================
FILE: packages/basic-modules/src/modules/todo/pre-parse-html.ts
================================================
/**
* @description pre parse html
* @author wangfupeng
*/
import $, { DOMElement } from '../../utils/dom'
/**
* pre-prase todo ,兼容 V4
* @param elem elem
*/
function preParse(elem: DOMElement): DOMElement {
const $elem = $(elem)
// $elem 格式如
// - hello world
const $li = $elem.find('li')
const $container = $('')
// 1. 把 input 移动到 $container
const $input = $li.find('input[type]')
$container.append($input)
// 2. 删除之前包裹 input 的 span
const $spanForInput = $li.children()[0]
$spanForInput.remove()
// 3. 再把剩余的内容移动到 $container (有纯文本内容,不能用 children ,得用 innerHTML)
$container[0].innerHTML = $container[0].innerHTML + $li[0].innerHTML
return $container[0]
}
export const preParseHtmlConf = {
selector: 'ul.w-e-todo', // 匹配 v4 todo
preParseHtml: preParse,
}
================================================
FILE: packages/basic-modules/src/modules/todo/render-elem.tsx
================================================
/**
* @description render todo
* @author wangfupeng
*/
import { Element as SlateElement, Transforms } from 'slate'
import { jsx, VNode } from 'snabbdom'
import { IDomEditor, DomEditor } from '@wangeditor/core'
import { TodoElement } from './custom-types'
/**
* render todo elem
* @param elemNode slate elem
* @param children children
* @param editor editor
* @returns vnode
*/
function renderTodo(elemNode: SlateElement, children: VNode[] | null, editor: IDomEditor): VNode {
// 判断 disabled
let disabled = false
if (editor.isDisabled()) disabled = true
const { checked } = elemNode as TodoElement
const vnode = (
{
const path = DomEditor.findPath(editor, elemNode)
const newProps: Partial = {
// @ts-ignore
checked: event.target.checked,
}
Transforms.setNodes(editor, newProps, { at: path })
},
}}
/>
{children}
)
return vnode
}
const renderTodoConf = {
type: 'todo', // 和 elemNode.type 一致
renderElem: renderTodo,
}
export { renderTodoConf }
================================================
FILE: packages/basic-modules/src/modules/undo-redo/index.ts
================================================
/**
* @description undo redo
* @author wangfupeng
*/
import { IModuleConf } from '@wangeditor/core'
import { redoMenuConf, undoMenuConf } from './menu/index'
const undoRedo: Partial = {
menus: [redoMenuConf, undoMenuConf],
}
export default undoRedo
================================================
FILE: packages/basic-modules/src/modules/undo-redo/menu/RedoMenu.ts
================================================
/**
* @description redo menu
* @author wangfupeng
*/
import { IButtonMenu, IDomEditor, t } from '@wangeditor/core'
import { REDO_SVG } from '../../../constants/icon-svg'
class RedoMenu implements IButtonMenu {
title = t('undo.redo')
iconSvg = REDO_SVG
tag = 'button'
getValue(editor: IDomEditor): string | boolean {
return ''
}
isActive(editor: IDomEditor): boolean {
return false
}
isDisabled(editor: IDomEditor): boolean {
if (editor.selection == null) return true
return false
}
exec(editor: IDomEditor, value: string | boolean) {
if (typeof editor.redo === 'function') {
editor.redo()
}
}
}
export default RedoMenu
================================================
FILE: packages/basic-modules/src/modules/undo-redo/menu/UndoMenu.ts
================================================
/**
* @description undo menu
* @author wangfupeng
*/
import { IButtonMenu, IDomEditor, t } from '@wangeditor/core'
import { UNDO_SVG } from '../../../constants/icon-svg'
class UndoMenu implements IButtonMenu {
title = t('undo.undo')
iconSvg = UNDO_SVG
tag = 'button'
getValue(editor: IDomEditor): string | boolean {
return ''
}
isActive(editor: IDomEditor): boolean {
return false
}
isDisabled(editor: IDomEditor): boolean {
if (editor.selection == null) return true
return false
}
exec(editor: IDomEditor, value: string | boolean) {
if (typeof editor.undo === 'function') {
editor.undo()
}
}
}
export default UndoMenu
================================================
FILE: packages/basic-modules/src/modules/undo-redo/menu/index.ts
================================================
/**
* @description menu entry
* @author wangfupeng
*/
import RedoMenu from './RedoMenu'
import UndoMenu from './UndoMenu'
export const undoMenuConf = {
key: 'undo',
factory() {
return new UndoMenu()
},
}
export const redoMenuConf = {
key: 'redo',
factory() {
return new RedoMenu()
},
}
================================================
FILE: packages/basic-modules/src/utils/dom.ts
================================================
/**
* @description DOM 操作
* @author wangfupeng
*/
import $, {
css,
append,
prepend,
addClass,
removeClass,
hasClass,
on,
off,
focus,
attr,
hide,
show,
parents,
dataset,
val,
text,
removeAttr,
children,
html,
remove,
find,
width,
height,
Dom7Array,
filter,
empty,
} from 'dom7'
export { Dom7Array } from 'dom7'
if (css) $.fn.css = css
if (append) $.fn.append = append
if (prepend) $.fn.prepend = prepend
if (addClass) $.fn.addClass = addClass
if (removeClass) $.fn.removeClass = removeClass
if (hasClass) $.fn.hasClass = hasClass
if (on) $.fn.on = on
if (off) $.fn.off = off
if (focus) $.fn.focus = focus
if (attr) $.fn.attr = attr
if (removeAttr) $.fn.removeAttr = removeAttr
if (hide) $.fn.hide = hide
if (show) $.fn.show = show
if (parents) $.fn.parents = parents
if (dataset) $.fn.dataset = dataset
if (val) $.fn.val = val
if (text) $.fn.text = text
if (html) $.fn.html = html
if (children) $.fn.children = children
if (remove) $.fn.remove = remove
if (find) $.fn.find = find
if (width) $.fn.width = width
if (height) $.fn.height = height
if (filter) $.fn.filter = filter
if (empty) $.fn.empty = empty
export default $
/**
* 判断 str 是不是纯字符串,而不是 html tag
* @param str str
*/
export function isPlainText(str: string) {
const $container = $(`${str}`)
// 获取 children length (过滤 `
`)
const childrenLength = $container.children().filter((child: DOMElement) => {
if (child.tagName === 'BR') return false
return true
}).length
return childrenLength === 0
}
/**
* 获取 outerHTML
* @param $elem dom7 elem
*/
export function getOuterHTML($elem: Dom7Array) {
if ($elem.length === 0) return ''
return $elem[0].outerHTML
}
/**
* 获取 tagName lower-case
* @param $elem $elem
*/
export function getTagName($elem: Dom7Array): string {
if ($elem.length) return $elem[0].tagName.toLowerCase()
return ''
}
/**
* 获取 $elem 某一个 style 值
* @param $elem $elem
* @param styleKey style key
*/
export function getStyleValue($elem: Dom7Array, styleKey: string): string {
let res = ''
const styleStr = $elem.attr('style') || '' // 如 'line-height: 2.5; color: red;'
const styleArr = styleStr.split(';') // 如 ['line-height: 2.5', ' color: red', '']
const length = styleArr.length
for (let i = 0; i < length; i++) {
const styleItemStr = styleArr[i] // 如 'line-height: 2.5'
if (styleItemStr) {
const arr = styleItemStr.split(':') // ['line-height', ' 2.5']
if (arr[0].trim() === styleKey) {
res = arr[1].trim()
}
}
}
return res
}
// 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 { DOMNode, DOMComment, DOMElement, DOMText, DOMRange, DOMSelection, DOMStaticRange }
================================================
FILE: packages/basic-modules/src/utils/util.ts
================================================
/**
* @description 工具函数
* @author wangfupeng
*/
import { nanoid } from 'nanoid'
/**
* 获取随机数字符串
* @param prefix 前缀
* @returns 随机数字符串
*/
export function genRandomStr(prefix: string = 'r'): string {
return `${prefix}-${nanoid()}`
}
export function replaceSymbols(str: string) {
return str.replace(//g, '>')
}
================================================
FILE: packages/basic-modules/src/utils/vdom.ts
================================================
/**
* @description vdom utils fn
* @author wangfupeng
*/
import { VNode, VNodeStyle, Dataset } from 'snabbdom'
// /**
// * 给 vnode 添加 dataset
// * @param vnode vnode
// * @param newDataset { key: val }
// */
// export function addVnodeDataset(vnode: VNode, newDataset: Dataset) {
// if (vnode.data == null) vnode.data = {}
// const data = vnode.data
// if (data.dataset == null) data.dataset = {}
// Object.assign(data.dataset, newDataset)
// }
/**
* 给 vnode 添加样式
* @param vnode vnode
* @param newStyle { key: val }
*/
export function addVnodeStyle(vnode: VNode, newStyle: VNodeStyle) {
if (vnode.data == null) vnode.data = {}
const data = vnode.data
if (data.style == null) data.style = {}
Object.assign(data.style, newStyle)
}
================================================
FILE: packages/basic-modules/tsconfig.json
================================================
{
"compilerOptions": {},
"extends": "../../tsconfig.json",
"include": [
"./src/**/*",
"../custom-types.d.ts"
]
}
================================================
FILE: packages/code-highlight/CHANGELOG.md
================================================
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.0.3](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/code-highlight@1.0.2...@wangeditor/code-highlight@1.0.3) (2022-09-14)
### Bug Fixes
* 代码块 - 增加 lua groovy 语言 ([ef4f62a](https://github.com/wangeditor-team/wangEditor/commit/ef4f62a876e95995f7c8f6f41d8d44b2505dd5f6))
## [1.0.2](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/code-highlight@1.0.1...@wangeditor/code-highlight@1.0.2) (2022-06-02)
### Bug Fixes
* issue 4308 - 自定义字号、字体无法回显 ([ad38b8c](https://github.com/wangeditor-team/wangEditor/commit/ad38b8ce6dbcff1d65785c8d6701238ad351f562))
## 1.0.1 (2022-04-18)
### Bug Fixes
* 更新各包之间依赖版本 ([75c552c](https://github.com/wangeditor-team/wangEditor/commit/75c552cc8ed54765bebb86a7ec5329a7fc79e85f))
* 修复 pnpm 安装 @wangeditor/editor 出现警告的问题 ([4087fbe](https://github.com/wangeditor-team/wangEditor/commit/4087fbee01c76bdd55e747a5e86c5e4a8d6a8353))
* 移除了每个包下的 publishConfig directory 配置 ([16559f0](https://github.com/wangeditor-team/wangEditor/commit/16559f052545c111318be760e64291a521bdcc65))
* 粘贴 代码块出错 ([fc44d9f](https://github.com/wangeditor-team/wangEditor/commit/fc44d9ff36cb9566d9dc5490b4be14f2e5bd3f3c))
* rename es module filename ([1821d4e](https://github.com/wangeditor-team/wangEditor/commit/1821d4eef49e64efcb41b848849ca7a5e6472044))
* table - elemToHtml ([e36e609](https://github.com/wangeditor-team/wangEditor/commit/e36e6092ef721723169afc8bf0560a47ac9f4dfc))
### Features
* code highlight ([42b2f8d](https://github.com/wangeditor-team/wangEditor/commit/42b2f8d192e2433593c11ad0b8424737f6cffb58))
* i18n ([c11b244](https://github.com/wangeditor-team/wangEditor/commit/c11b2440f91b99d40bca18b675c66a22b6e160c9))
* parse html ([2a5eace](https://github.com/wangeditor-team/wangEditor/commit/2a5eace00f33cded50b68e8164748ec2480213fd))
* toHtml 机制 ([1c4d872](https://github.com/wangeditor-team/wangEditor/commit/1c4d8729f84aaab6a448f23064b34a20596305e9))
* upload video ([ac8e6f8](https://github.com/wangeditor-team/wangEditor/commit/ac8e6f8b5258e593714676a6f6be359ba525833c))
================================================
FILE: packages/code-highlight/README.md
================================================
# wangEditor code highlight
Code highlight module built in [wangEditor](https://www.wangeditor.com/) by default.
================================================
FILE: packages/code-highlight/__tests__/content.ts
================================================
/**
* @description code content
* @author wangfupeng
*/
export const text = 'const a = 100;'
export const textNode = { text: text }
export const language = 'javascript'
export const codeNode = {
type: 'code',
language,
children: [textNode],
}
export const preNode = {
type: 'pre',
children: [codeNode],
}
export const content = [{ type: 'paragraph', children: [{ text: 'hello world' }] }, preNode]
export const textNodePath = [1, 0, 0]
export const codeLocation = {
anchor: { offset: text.length, path: textNodePath },
focus: { offset: text.length, path: textNodePath },
}
export const paragraphLocation = {
anchor: { offset: 0, path: [0, 0] },
focus: { offset: 0, path: [0, 0] },
}
describe('加一个 case 防止报错~', () => {
it('1 + 1 = 2', () => {
expect(1 + 1).toBe(2)
})
})
================================================
FILE: packages/code-highlight/__tests__/decorate.test.ts
================================================
/**
* @description code-highlight decorate test
* @author wangfupeng
*/
import { IDomEditor } from '@wangeditor/core'
import createEditor from '../../../tests/utils/create-editor'
import codeHighLightDecorate from '../src/decorate/index'
import { content, textNode, textNodePath } from './content'
describe('code-highlight decorate', () => {
let editor: IDomEditor | null = null
beforeAll(() => {
// 把 content 创建到一个编辑器中
editor = createEditor({
content,
})
})
afterAll(() => {
// 销毁 editor
if (editor == null) return
editor.destroy()
editor = null
})
it('code-highlight decorate 拆分代码字符串', () => {
const ranges = codeHighLightDecorate([textNode, textNodePath])
expect(ranges.length).toBe(4) // 把 textNode 内容拆分为 4 段
})
})
================================================
FILE: packages/code-highlight/__tests__/elem-to-html.test.ts
================================================
/**
* @description code-hight elem-to-html
* @author wangfupeng
*/
import { IDomEditor } from '@wangeditor/core'
import createEditor from '../../../tests/utils/create-editor'
import { codeToHtmlConf } from '../src/module/elem-to-html'
import { content, codeNode, language } from './content'
describe('code-highlight elem to html', () => {
let editor: IDomEditor | null = null
beforeAll(() => {
// 把 content 创建到一个编辑器中
editor = createEditor({
content,
})
})
afterAll(() => {
// 销毁 editor
if (editor == null) return
editor.destroy()
editor = null
})
it('codeNode to html', () => {
expect(codeToHtmlConf.type).toBe('code')
if (editor == null) throw new Error('editor is null')
const text = 'var n = 100;'
const html = codeToHtmlConf.elemToHtml(codeNode, text)
expect(html).toBe(`${text}`)
})
})
================================================
FILE: packages/code-highlight/__tests__/parse-html.test.ts
================================================
/**
* @description parse html test
* @author wangfupeng
*/
import { $ } from 'dom7'
import { parseCodeStyleHtml } from '../src/module/parse-style-html'
import createEditor from '../../../tests/utils/create-editor'
describe('code highlight - parse style html', () => {
const editor = createEditor()
it('v5 format', () => {
const $code = $('') // v5 html format
const code = { type: 'code', children: [{ text: 'var a = 100;' }] }
const res = parseCodeStyleHtml($code[0], code, editor)
expect(res).toEqual({
type: 'code',
language: 'javascript',
children: [{ text: 'var a = 100;' }],
})
})
it('v4 format', () => {
const $code = $('') // v4 html format
const code = { type: 'code', children: [{ text: 'var a = 100;' }] }
const res = parseCodeStyleHtml($code[0], code, editor)
expect(res).toEqual({
type: 'code',
language: 'javascript',
children: [{ text: 'var a = 100;' }],
})
})
})
================================================
FILE: packages/code-highlight/__tests__/render-text-style.test.tsx
================================================
/**
* @description code-highlight render text style test
* @author wangfupeng
*/
import { renderStyle } from '../src/module/render-style'
import { jsx } from 'snabbdom'
describe('code-highlight render text style', () => {
it('code text style', () => {
const leafNode = { text: 'let', keyword: true } // 定义一个 keyword leaf text node
const vnode = let
// @ts-ignore 忽略 vnode 格式检查
const newVnode = renderStyle(leafNode, vnode)
expect(newVnode.data?.props?.className).toBe('token keyword')
})
})
================================================
FILE: packages/code-highlight/__tests__/select-lang-menu.test.ts
================================================
/**
* @description code-highlight select lang menu test
* @author wangfupeng
*/
import { IDomEditor } from '@wangeditor/core'
import createEditor from '../../../tests/utils/create-editor'
import { content, codeLocation, paragraphLocation, language } from './content'
import SelectLangMenu from '../src/module/menu/SelectLangMenu'
describe('code-highlight select lang menu', () => {
let editor: IDomEditor | null = null
let menu: SelectLangMenu | null = null
beforeAll(() => {
// 创建 editor
editor = createEditor({
content,
})
// 创建 menu
menu = new SelectLangMenu()
})
afterAll(() => {
// 销毁 editor
if (editor == null) return
editor.destroy()
editor = null
// 销毁 menu
menu = null
})
it('get langs and selected one', () => {
if (editor == null || menu == null) throw new Error('editor or menu is null')
// select codeNode
editor.select(codeLocation)
const langs = menu.getOptions(editor)
// 包括多个 lang
expect(langs.length).toBeGreaterThan(0)
// 其中有一个 'plain text'
const hasPlainText = langs.some(lang => lang.text === 'plain text' && lang.value === '')
expect(hasPlainText).toBeTruthy()
// 选中的语言
const selectedLangs = langs.filter(lang => lang.selected)
expect(selectedLangs.length).toBe(1)
const selectedLang: any = selectedLangs[0] || {}
expect(selectedLang.value).toBe(language)
})
it('menu active is always false', () => {
if (editor == null || menu == null) throw new Error('editor or menu is null')
expect(menu.isActive(editor)).toBeFalsy()
})
it('get menu value (selected lang)', () => {
if (editor == null || menu == null) throw new Error('editor or menu is null')
// select codeNode
editor.select(codeLocation)
expect(menu.getValue(editor)).toBe(language)
// select paragraph
editor.select(paragraphLocation)
expect(menu.getValue(editor)).toBe('')
})
it('menu disable', () => {
if (editor == null || menu == null) throw new Error('editor or menu is null')
// deselect
editor.deselect()
expect(menu.isDisabled(editor)).toBeTruthy()
// select paragraph
editor.select(paragraphLocation)
expect(menu.isDisabled(editor)).toBeTruthy()
// select codeNode
editor.select(codeLocation)
expect(menu.isDisabled(editor)).toBeFalsy()
})
it('menu exec (change lang)', done => {
if (editor == null || menu == null) throw new Error('editor or menu is null')
// select codeNode
editor.select(codeLocation)
menu.exec(editor, 'html') // change lang
setTimeout(() => {
if (editor == null || menu == null) return
editor.select(codeLocation)
expect(menu.getValue(editor)).toBe('html')
done()
})
})
})
================================================
FILE: packages/code-highlight/package.json
================================================
{
"name": "@wangeditor/code-highlight",
"version": "1.0.3",
"description": "wangEditor code-highlight module",
"author": "wangfupeng1988 ",
"contributors": [],
"homepage": "https://github.com/wangeditor-team/wangEditor#readme",
"license": "MIT",
"types": "dist/code-highlight/src/index.d.ts",
"main": "dist/index.js",
"module": "dist/index.esm.js",
"browser": {
"./dist/index.js": "./dist/index.js",
"./dist/index.esm.js": "./dist/index.esm.js"
},
"directories": {
"lib": "dist",
"test": "__tests__"
},
"files": [
"dist"
],
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.com/"
},
"repository": {
"type": "git",
"url": "git+https://github.com/wangeditor-team/wangEditor.git"
},
"scripts": {
"test": "jest",
"test-c": "jest --coverage",
"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-team/wangEditor/issues"
},
"peerDependencies": {
"@wangeditor/core": "1.x",
"dom7": "^3.0.0",
"slate": "^0.72.0",
"snabbdom": "^3.1.0"
},
"dependencies": {
"prismjs": "^1.23.0"
},
"devDependencies": {
"@types/prismjs": "^1.16.5"
}
}
================================================
FILE: packages/code-highlight/rollup.config.js
================================================
import { createRollupConfig, IS_PRD } from '../../build/create-rollup-config'
import pkg from './package.json'
const name = 'WangEditorCodeHighLight'
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
================================================
FILE: packages/code-highlight/src/assets/index.less
================================================
// 样式参考 https://github.com/PrismJS/prism/blob/master/themes/prism.css
// TODO 开发 themes 主题,可以参考 prismjs 主题 https://github.com/PrismJS/prism/tree/master/themes
.w-e-text-container [data-slate-editor] pre>code {
text-shadow: 0 1px white;
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
tab-size: 4;
hyphens: none;
padding: 1em;
margin: .5em 0;
overflow: auto;
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: slategray;
}
.token.punctuation {
color: #999;
}
.token.namespace {
opacity: .7;
}
.token.property,
.token.tag,
.token.boolean,
.token.number,
.token.constant,
.token.symbol,
.token.deleted {
color: #905;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin,
.token.inserted {
color: #690;
}
.token.operator,
.token.entity,
.token.url,
.language-css .token.string,
.style .token.string {
color: #9a6e3a;
}
.token.atrule,
.token.attr-value,
.token.keyword {
color: #07a;
}
.token.function,
.token.class-name {
color: #DD4A68;
}
.token.regex,
.token.important,
.token.variable {
color: #e90;
}
.token.important,
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}
}
================================================
FILE: packages/code-highlight/src/constants/svg.ts
================================================
/**
* @description icon svg
* @author wangfupeng
*/
/**
* 【注意】svg 字符串的长度 ,否则会导致代码体积过大
* 尽量选择 https://www.iconfont.cn/collections/detail?spm=a313x.7781069.0.da5a778a4&cid=20293
* 找不到再从 iconfont.com 搜索
*/
export const JS_SVG =
''
================================================
FILE: packages/code-highlight/src/custom-types.ts
================================================
/**
* @description 自定义 element
* @author wangfupeng
*/
// 拷贝自 basic-modules/src/modules/code-block/custom-types.ts
type PureText = {
text: string
}
export type PreElement = {
type: 'pre'
children: CodeElement[]
}
export type CodeElement = {
type: 'code'
language: string
children: PureText[]
}
================================================
FILE: packages/code-highlight/src/decorate/index.ts
================================================
/**
* @description code-highlight decorate
* @author wangfupeng
*/
import { Node, NodeEntry, Range, Text } from 'slate'
import { DomEditor } from '@wangeditor/core'
import { getPrismTokens, getPrismTokenLength } from '../vendor/prism'
import { CodeElement } from '../custom-types'
/**
* 获取 code elem
* @param node text node
*/
function getCodeElem(textNode: Node): CodeElement | null {
if (!Text.isText(textNode)) return null // 非文本 node
const codeNode = DomEditor.getParentNode(null, textNode)
if (codeNode && DomEditor.getNodeType(codeNode) === 'code') {
const preNode = DomEditor.getParentNode(null, codeNode)
if (preNode && DomEditor.getNodeType(preNode) === 'pre') {
return codeNode as CodeElement
}
}
return null
}
const codeHighLightDecorate = (nodeEntry: NodeEntry): Range[] => {
const [n, path] = nodeEntry
const ranges: Range[] = []
// 节点不合法,则不处理
const codeElem = getCodeElem(n)
if (codeElem == null) return ranges
const { language = '' } = codeElem
if (!language) return ranges
const textNode = n as Text
const tokens = getPrismTokens(textNode, language)
let start = 0
for (const token of tokens) {
const length = getPrismTokenLength(token)
const end = start + length
if (typeof token !== 'string') {
// 遇到关键字,则拆分多个 range —— decorate 规则
ranges.push({
[token.type]: true, // 记录类型,以便 css 使用不同的颜色
anchor: { path, offset: start },
focus: { path, offset: end },
})
}
start = end
}
return ranges
}
export default codeHighLightDecorate
================================================
FILE: packages/code-highlight/src/index.ts
================================================
/**
* @description code-highlight
* @author wangfupeng
*/
import './assets/index.less'
// 配置多语言
import './locale/index'
import wangEditorCodeHighlightModule from './module/index'
import wangEditorCodeHighLightDecorate from './decorate'
export { wangEditorCodeHighlightModule, wangEditorCodeHighLightDecorate }
================================================
FILE: packages/code-highlight/src/locale/en.ts
================================================
/**
* @description i18n en
* @author wangfupeng
*/
export default {
highLightModule: {
selectLang: 'Language',
},
}
================================================
FILE: packages/code-highlight/src/locale/index.ts
================================================
/**
* @description i18n entry
* @author wangfupeng
*/
import { i18nAddResources } from '@wangeditor/core'
import enResources from './en'
import zhResources from './zh-CN'
i18nAddResources('en', enResources)
i18nAddResources('zh-CN', zhResources)
================================================
FILE: packages/code-highlight/src/locale/zh-CN.ts
================================================
/**
* @description i18n zh-CN
* @author wangfupeng
*/
export default {
highLightModule: {
selectLang: '选择语言',
},
}
================================================
FILE: packages/code-highlight/src/module/elem-to-html.ts
================================================
/**
* @description to html
* @author wangfupeng
*/
import { Element } from 'slate'
import { CodeElement } from '../custom-types'
function codeToHtml(elem: Element, childrenHtml: string): string {
const { language = '' } = elem as CodeElement
const cssClass = language
? `class="language-${language}"` // prism.js 根据 language 代码高亮
: ''
return `${childrenHtml}`
}
// 覆盖 basic-module 中的 code to html
export const codeToHtmlConf = {
type: 'code',
elemToHtml: codeToHtml,
}
================================================
FILE: packages/code-highlight/src/module/index.ts
================================================
/**
* @description code highlight module
* @author wangfupeng
*/
import { IModuleConf } from '@wangeditor/core'
import { renderStyle } from './render-style'
import { parseCodeStyleHtml } from './parse-style-html'
import { selectLangMenuConf } from './menu/index'
import { codeToHtmlConf } from './elem-to-html'
const codeHighlightModule: Partial = {
renderStyle,
parseStyleHtml: parseCodeStyleHtml,
menus: [selectLangMenuConf],
elemsToHtml: [codeToHtmlConf],
}
export default codeHighlightModule
================================================
FILE: packages/code-highlight/src/module/menu/SelectLangMenu.ts
================================================
/**
* @description code-highlight select lang
* @author wangfupeng
*/
import { Transforms, Element } from 'slate'
import { ISelectMenu, IDomEditor, IOption, DomEditor, t } from '@wangeditor/core'
import { JS_SVG } from '../../constants/svg'
import { CodeElement } from '../../custom-types'
class SelectLangMenu implements ISelectMenu {
readonly title = t('highLightModule.selectLang')
readonly iconSvg = JS_SVG
readonly tag = 'select'
readonly width = 95
readonly selectPanelWidth = 115
getOptions(editor: IDomEditor): IOption[] {
const options: IOption[] = []
// 获取配置,参考 './config.ts'
const { codeLangs = [] } = editor.getMenuConfig('codeSelectLang') // 第二个参数 menu key
options.push({
text: 'plain text',
value: '', // getValue 默认会返回 ''
})
codeLangs.forEach((lang: { text: string; value: string }) => {
const { text, value } = lang
options.push({ text, value })
})
// 设置 selected
const curValue = this.getValue(editor)
options.forEach(opt => {
if (opt.value === curValue) {
opt.selected = true
} else {
delete opt.selected
}
})
return options
}
isActive(editor: IDomEditor): boolean {
// select menu 会显示 selected value ,用不到 active
return false
}
/**
* 获取语言类型
* @param editor editor
*/
getValue(editor: IDomEditor): string | boolean {
const elem = this.getSelectCodeElem(editor)
if (elem == null) return ''
if (!Element.isElement(elem)) return ''
const lang = elem.language.toString()
// 当前 elem.language 是否在已配置的 langs 中?
const { codeLangs = [] } = editor.getMenuConfig('codeSelectLang')
const hasLang = codeLangs.some(item => item.value === lang)
if (hasLang) return lang
return ''
}
isDisabled(editor: IDomEditor): boolean {
if (editor.selection == null) return true
const elem = this.getSelectCodeElem(editor)
if (elem) return false
return true
}
exec(editor: IDomEditor, value: string | boolean) {
const elem = this.getSelectCodeElem(editor)
if (elem == null) return
// 设置语言
const props: Partial = { language: value.toString() }
Transforms.setNodes(editor, props, {
match: n => DomEditor.checkNodeType(n, 'code'),
})
}
private getSelectCodeElem(editor: IDomEditor): CodeElement | null {
const codeNode = DomEditor.getSelectedNodeByType(editor, 'code')
if (codeNode == null) return null
const preNode = DomEditor.getParentNode(editor, codeNode)
if (!Element.isElement(preNode)) return null
if (preNode.type !== 'pre') return null
return codeNode as CodeElement
}
}
export default SelectLangMenu
================================================
FILE: packages/code-highlight/src/module/menu/config.ts
================================================
/**
* @description menu config
* @author wangfupeng
*/
export function genCodeLangs() {
// 1. text value 对应关系参考 prism 官网 https://prismjs.com/#supported-languages
// 2. 要加入一个新语言时,要引入相应的 js 模块(代码在 `vender/prism.ts`),例如 `import 'prismjs/components/prism-php'`
return [
{ text: 'CSS', value: 'css' },
{ text: 'HTML', value: 'html' },
{ text: 'XML', value: 'xml' },
{ text: 'Javascript', value: 'javascript' },
{ text: 'Typescript', value: 'typescript' },
{ text: 'JSX', value: 'jsx' },
{ text: 'Go', value: 'go' },
{ text: 'PHP', value: 'php' },
{ text: 'C', value: 'c' },
{ text: 'Python', value: 'python' },
{ text: 'Java', value: 'java' },
{ text: 'C++', value: 'cpp' },
{ text: 'C#', value: 'csharp' },
{ text: 'Visual Basic', value: 'visual-basic' },
{ text: 'SQL', value: 'sql' },
{ text: 'Ruby', value: 'ruby' },
{ text: 'Swift', value: 'swift' },
{ text: 'Bash', value: 'bash' },
{ text: 'Lua', value: 'lua' },
{ text: 'Groovy', value: 'groovy' },
{ text: 'Markdown', value: 'markdown' },
]
}
================================================
FILE: packages/code-highlight/src/module/menu/index.ts
================================================
/**
* @description code-highlight menu
* @author wangfupeng
*/
import SelectLangMenu from './SelectLangMenu'
import { genCodeLangs } from './config'
export const selectLangMenuConf = {
key: 'codeSelectLang',
factory() {
return new SelectLangMenu()
},
config: {
codeLangs: genCodeLangs(),
},
}
================================================
FILE: packages/code-highlight/src/module/parse-style-html.ts
================================================
/**
* @description parse style html
* @author wangfupeng
*/
import $, { DOMElement } from '../utils/dom'
import { Descendant, Element } from 'slate'
import { DomEditor, IDomEditor } from '@wangeditor/core'
import { CodeElement } from '../custom-types'
export function parseCodeStyleHtml(
elem: DOMElement,
node: Descendant,
editor: IDomEditor
): Descendant {
const $elem = $(elem)
if (!Element.isElement(node)) return node
if (DomEditor.getNodeType(node) !== 'code') return node // 只针对 pre/code 元素
const elemNode = node as CodeElement
const langAttr = $elem.attr('class') || ''
if (langAttr.indexOf('language-') === 0) {
// V5 版本,格式如 class="language-javascript"
elemNode.language = langAttr.split('-')[1] || '' // 获取 'javascript'
} else {
// 兼容 V4 版本,格式如 class="Javascript"
elemNode.language = langAttr.toLowerCase()
}
return elemNode
}
================================================
FILE: packages/code-highlight/src/module/render-style.tsx
================================================
/**
* @description render code highlight style
* @author wangfupeng
*/
import { Text as SlateText, Descendant } from 'slate'
import { jsx, VNode } from 'snabbdom'
import { addVnodeClassName } from '../utils/vdom'
import { prismTokenTypes } from '../vendor/prism'
/**
* 添加样式
* @param node slate text
* @param vnode vnode
* @returns vnode
*/
export function renderStyle(node: Descendant, vnode: VNode): VNode {
const leafNode = node as SlateText & { [key: string]: string }
let styleVnode: VNode = vnode
let className = ''
prismTokenTypes.forEach(type => {
if (leafNode[type]) className = type
})
if (className) {
className = `token ${className}` // 如 'token keyword' - prismjs 渲染的规则
addVnodeClassName(styleVnode, className)
}
return styleVnode
}
================================================
FILE: packages/code-highlight/src/utils/dom.ts
================================================
/**
* @description DOM 操作
* @author wangfupeng
*/
import $, { attr } from 'dom7'
if (attr) $.fn.attr = attr
export { Dom7Array } from 'dom7'
export default $
// 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 { DOMNode, DOMComment, DOMElement, DOMText, DOMRange, DOMSelection, DOMStaticRange }
================================================
FILE: packages/code-highlight/src/utils/vdom.ts
================================================
/**
* @description vdom utils fn
* @author wangfupeng
*/
import { VNode, VNodeStyle } from 'snabbdom'
/**
* 给 vnode 添加 className
* @param vnode vnode
* @param className css class
*/
export function addVnodeClassName(vnode: VNode, className: string) {
if (vnode.data == null) vnode.data = {}
const data = vnode.data
if (data.props == null) data.props = {}
Object.assign(data.props, { className })
}
/**
* 给 vnode 添加样式
* @param vnode vnode
* @param newStyle { key: val }
*/
export function addVnodeStyle(vnode: VNode, newStyle: VNodeStyle) {
if (vnode.data == null) vnode.data = {}
const data = vnode.data
if (data.style == null) data.style = {}
Object.assign(data.style, newStyle)
}
================================================
FILE: packages/code-highlight/src/vendor/prism.ts
================================================
/**
* @description prismjs
* @author wangfupeng
*/
import { Text } from 'slate'
import Prism from 'prismjs'
import 'prismjs/components/prism-jsx'
import 'prismjs/components/prism-typescript'
import 'prismjs/components/prism-markup'
import 'prismjs/components/prism-go'
import 'prismjs/components/prism-php'
import 'prismjs/components/prism-c'
import 'prismjs/components/prism-python'
import 'prismjs/components/prism-java'
import 'prismjs/components/prism-cpp'
import 'prismjs/components/prism-csharp'
import 'prismjs/components/prism-visual-basic'
import 'prismjs/components/prism-sql'
import 'prismjs/components/prism-ruby'
import 'prismjs/components/prism-swift'
import 'prismjs/components/prism-bash'
import 'prismjs/components/prism-markdown'
import 'prismjs/components/prism-lua'
import 'prismjs/components/prism-groovy'
// 语言模块,参考 https://github.com/PrismJS/prism/tree/master/components
// prismjs 的 token 类型汇总
export const prismTokenTypes = [
'comment',
'prolog',
'doctype',
'cdata',
'punctuation',
'namespace',
'property',
'tag',
'boolean',
'number',
'constant',
'symbol',
'deleted',
'selector',
'attr-name',
'string',
'builtin',
'inserted',
'operator',
'entity',
'url',
'string',
'atrule',
'attr-value',
'keyword',
'function',
'class-name',
'regex',
'important',
'variable',
'bold',
'italic',
'entity',
'char',
]
/**
* 获取 prism token 的字符串长度
* @param token prism token
*/
export function getPrismTokenLength(token: any) {
if (typeof token === 'string') {
return token.length
} else if (typeof token.content === 'string') {
return token.content.length
} else {
// 累加 length
return token.content.reduce(
// @ts-ignore
(l, t) => l + getPrismTokenLength(t),
0
)
}
}
/**
* 获取 prism 解析的 token 列表
* @param textNode text node
* @param language 代码语言
*/
export function getPrismTokens(textNode: Text, language: string) {
if (!language) return []
const langGrammar = Prism.languages[language]
if (!langGrammar) return []
return Prism.tokenize(textNode.text, langGrammar)
// tokens 即 Prism 对整个字符串的拆分,有普通文字也有高亮的关键字
// 例如 `const a = 100;` 的 tokens 是一个数组 [ token, ' a ', token, ' ', token ] ,有对象有字符串,对象就表示关键字
// 如数组第一个 token 是 { type: "keyword", content: "const" } 。关键字类型不同 type 也不同
}
================================================
FILE: packages/code-highlight/tsconfig.json
================================================
{
"compilerOptions": {},
"extends": "../../tsconfig.json",
"include": [
"./src/**/*",
"../custom-types.d.ts"
]
}
================================================
FILE: packages/core/CHANGELOG.md
================================================
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.1.19](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.1.18...@wangeditor/core@1.1.19) (2022-11-14)
### Bug Fixes
* **font family menu:** 处理 setHtml 的时候字体样式回显失败的问题 ([b941bab](https://github.com/wangeditor-team/wangEditor/commit/b941babbdc6bd5bf7da0cce826803a8fde011e07))
* **fontFamily menu:** fix font-family value quote symbol ([2c25231](https://github.com/wangeditor-team/wangEditor/commit/2c25231a088de14edbf7516fc448a6483125e3ed))
## [1.1.18](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.1.17...@wangeditor/core@1.1.18) (2022-10-18)
### Bug Fixes
* mousedown事件添加passive的默认值 ([60229cc](https://github.com/wangeditor-team/wangEditor/commit/60229cc2f9647a5f17dc0fd85c4bb1dc396a5e9c))
* **video menu:** fix invoke clear api can not clear video node when insert video ([68c1f8e](https://github.com/wangeditor-team/wangEditor/commit/68c1f8ee68ab2cb7b202b6d9b4d4db192a927725))
## [1.1.17](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.1.16...@wangeditor/core@1.1.17) (2022-10-04)
### Bug Fixes
* 修复 compositionend 时错误修改dom的问题 ([1187154](https://github.com/wangeditor-team/wangEditor/commit/1187154aa077594f55211307c00e3493d1ab5676))
* 修复设置 maxlength 后粘贴异常的问题 ([14003d0](https://github.com/wangeditor-team/wangEditor/commit/14003d0ba01eeb9a264d15fac514dd4b4bd89ff7))
## [1.1.16](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.1.15...@wangeditor/core@1.1.16) (2022-09-27)
### Bug Fixes
* list-item - 遇到 style 是 toHtml 出错 ([9854308](https://github.com/wangeditor-team/wangEditor/commit/98543083a1cb09207aceb2a4d8f3c1ce020b106d))
## [1.1.15](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.1.14...@wangeditor/core@1.1.15) (2022-09-27)
**Note:** Version bump only for package @wangeditor/core
## [1.1.14](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.1.13...@wangeditor/core@1.1.14) (2022-09-16)
**Note:** Version bump only for package @wangeditor/core
## [1.1.13](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.1.12...@wangeditor/core@1.1.13) (2022-09-15)
### Bug Fixes
* focus table 时 isFocused 异常 ([5c52bf3](https://github.com/wangeditor-team/wangEditor/commit/5c52bf33e91b1a4677e7bbc04c5d80698abfeeab))
* snabbdom 增加 attributesModule ([2c597b6](https://github.com/wangeditor-team/wangEditor/commit/2c597b6a52ffa96c820128d63fd84b903a6faebf))
## [1.1.12](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.1.11...@wangeditor/core@1.1.12) (2022-08-30)
### Bug Fixes
* fix https://github.com/wangeditor-team/wangEditor/issues/4754 ([e0216b9](https://github.com/wangeditor-team/wangEditor/commit/e0216b98b0ea9ebf4f9cc8a8fd820d68fcd230d3))
## [1.1.11](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.1.10...@wangeditor/core@1.1.11) (2022-07-27)
**Note:** Version bump only for package @wangeditor/core
## [1.1.10](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.1.9...@wangeditor/core@1.1.10) (2022-07-27)
### Bug Fixes
* setHtml 支持空字符串 ([d438157](https://github.com/wangeditor-team/wangEditor/commit/d43815766320d9cb0548bae0415c54ce7b147efb))
* upload file callback error ([bf20e07](https://github.com/wangeditor-team/wangEditor/commit/bf20e07f12ed242b0ab4bb2290d876153a822972))
## [1.1.9](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.1.8...@wangeditor/core@1.1.9) (2022-07-22)
### Bug Fixes
* 粘贴 HTML bug ([b935ef6](https://github.com/wangeditor-team/wangEditor/commit/b935ef622b9d4f8f3a9954d26a41c89d4e8042bd))
## [1.1.8](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.1.7...@wangeditor/core@1.1.8) (2022-07-18)
### Bug Fixes
* 粘贴文字报错 ([a11ea56](https://github.com/wangeditor-team/wangEditor/commit/a11ea56af4f7976f5664232e80a164cd37d84d8c))
## [1.1.7](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.1.6...@wangeditor/core@1.1.7) (2022-07-16)
### Bug Fixes
* setHtml() 多一个空行 ([994954f](https://github.com/wangeditor-team/wangEditor/commit/994954fcbae72808e3488e0936a5f82253b603f4))
* 图片受 indent 影响 ([3d737f1](https://github.com/wangeditor-team/wangEditor/commit/3d737f11e457c46e1aeee40ebd834a2470198dfd))
## [1.1.6](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.1.5...@wangeditor/core@1.1.6) (2022-07-14)
### Bug Fixes
* 粘贴网页 HTML 报错 ([939cb22](https://github.com/wangeditor-team/wangEditor/commit/939cb2229a11eea827e1bea4420f7502db1e7eb6))
## [1.1.5](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.1.4...@wangeditor/core@1.1.5) (2022-07-13)
### Bug Fixes
* setHtml 问题 - table 后面 p 格式错误 ([b525b4a](https://github.com/wangeditor-team/wangEditor/commit/b525b4aaa69b834204232774971367beba7db975))
## [1.1.4](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.1.3...@wangeditor/core@1.1.4) (2022-07-12)
**Note:** Version bump only for package @wangeditor/core
## [1.1.3](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.1.2...@wangeditor/core@1.1.3) (2022-07-11)
### Bug Fixes
* scroll 滚动问题 ([bc133e1](https://github.com/wangeditor-team/wangEditor/commit/bc133e1e4ca89ab5042cbc0971578ad144499805))
* 修复选中内容输入时,出现光标位置不对或者输入重复内容的问题 ([9596a4c](https://github.com/wangeditor-team/wangEditor/commit/9596a4ccaca2e2c4eed7ffc16fc4b042f73cef5d))
## [1.1.2](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.1.1...@wangeditor/core@1.1.2) (2022-07-11)
### Bug Fixes
* editor.focus() 参数语法错误 ([334fa21](https://github.com/wangeditor-team/wangEditor/commit/334fa217d43fdaa95454e7c85a53526b7b777fda))
* focus blur 问题 ([4a1997b](https://github.com/wangeditor-team/wangEditor/commit/4a1997b9f19cdce9d6aa6ff4e8e13d439b12af05))
* 单词之间空格问题 issue 4403 ([2f1d6f5](https://github.com/wangeditor-team/wangEditor/commit/2f1d6f5275c8a9e106b66213bb276c58a70aff79))
## [1.1.1](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.1.0...@wangeditor/core@1.1.1) (2022-06-02)
### Bug Fixes
* issue 4308 - 自定义字号、字体无法回显 ([ad38b8c](https://github.com/wangeditor-team/wangEditor/commit/ad38b8ce6dbcff1d65785c8d6701238ad351f562))
# [1.1.0](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.0.1...@wangeditor/core@1.1.0) (2022-05-25)
### Bug Fixes
* 修复 readonly 模式下,特定内容下editor初始化报错的问题 ([f3bc8b8](https://github.com/wangeditor-team/wangEditor/commit/f3bc8b8d485765cfa8fa7d19e530aa1a1b4bc4e2))
* 粘贴 HTML 后 font-size font-family line-height 不显示 ([2281957](https://github.com/wangeditor-team/wangEditor/commit/2281957020a30de9cda1c5e9d5e20c6668b7f592))
### Features
* editVideoSize ([375eecb](https://github.com/wangeditor-team/wangEditor/commit/375eecba826eac681268c55c47bcd922f7157d63))
* setHtml ([f4f91b8](https://github.com/wangeditor-team/wangEditor/commit/f4f91b883298091e3679ca6b206ae0d796003772))
## 1.0.1 (2022-04-18)
### Bug Fixes
* 部分菜单 disabled ([87f1233](https://github.com/wangeditor-team/wangEditor/commit/87f12332a087072406c1988dc5cef2eae8335375))
* 错别字 alwaysEnable ([82c5136](https://github.com/wangeditor-team/wangEditor/commit/82c5136f8496be420dfa26b0f30522e19924a907))
* 弹出 modal 时 blur ([53454ef](https://github.com/wangeditor-team/wangEditor/commit/53454ef74b0775391aecf2d745561c9281715934))
* 点击编辑器区域,未关闭 dropPanel ([b23123b](https://github.com/wangeditor-team/wangEditor/commit/b23123bb361ac2acadcacdfeaa78dd7bf878f86e))
* 多余的空行 ([4af6c64](https://github.com/wangeditor-team/wangEditor/commit/4af6c648861c2c56db62fae28e9dfa0d27ca5d51))
* 多余的空行 ([9dde85c](https://github.com/wangeditor-team/wangEditor/commit/9dde85cec5a27be21e0b89c24288d418e1f6d2de))
* 更新各包之间依赖版本 ([75c552c](https://github.com/wangeditor-team/wangEditor/commit/75c552cc8ed54765bebb86a7ec5329a7fc79e85f))
* 获取 activeElement 兼容 Document 和 ShadowRoot ([d904e5d](https://github.com/wangeditor-team/wangEditor/commit/d904e5dc263ce670362779b0cfa51ca9f7a8bd86))
* 拼音输入 bug we-2021/issues/47 ([20b7429](https://github.com/wangeditor-team/wangEditor/commit/20b74298509d9463d6aa1aaffabc21bd33bd7857))
* 拼音隐藏 placeholder ([aec1a9f](https://github.com/wangeditor-team/wangEditor/commit/aec1a9f62af8944b7894beeca953076ec73545d5))
* 全屏边距 ([1acb129](https://github.com/wangeditor-team/wangEditor/commit/1acb12974848af28e2d0f574f85a59145675cdbc))
* 全选 ([3cb8f42](https://github.com/wangeditor-team/wangEditor/commit/3cb8f428a0b94c280b63d42f46c148a9f0e2d9fd))
* 上传图片 - base64 仍触发上传 + 超出 maxSize 的报错提醒 ([a1d469a](https://github.com/wangeditor-team/wangEditor/commit/a1d469accb7f87f8ea0282a1699d002aaaa4e79a))
* 使用了 ts 类型空间导入方式优化 ([5d7b509](https://github.com/wangeditor-team/wangEditor/commit/5d7b5094e561af138b2569c669fd4daad2808f73))
* 图片上传,提示 ([3754012](https://github.com/wangeditor-team/wangEditor/commit/37540129dff1212c5ebfd4ca3f4d4e8def735e73))
* 完善了 isDOMEventHandled ([745f1d7](https://github.com/wangeditor-team/wangEditor/commit/745f1d7b949eb8839cbdb0fb1690c33c386b697f))
* 完善了 metaWithUrl 类型声明 ([3542834](https://github.com/wangeditor-team/wangEditor/commit/3542834b9aa65eba5b1c352d106f6623e5fcdc06))
* 修复 firefox 上全选编辑器内容使用拼音输入异常 ([87dafcb](https://github.com/wangeditor-team/wangEditor/commit/87dafcbe4c51d588ac97d3825a9389571fa16404))
* 修复 modal 中的 input 没有被 focus ([484c51e](https://github.com/wangeditor-team/wangEditor/commit/484c51e4629defe9eac3f2acaf83ccb62a669d5d))
* 修复 modal close 时没有恢复选区的问题 ([16f5a57](https://github.com/wangeditor-team/wangEditor/commit/16f5a57b2815026741249e8b4ef9e7222071353f))
* 修复回车超过视口后没有自动滚动的问题 ([f088b52](https://github.com/wangeditor-team/wangEditor/commit/f088b52ff8c9386ba9efc2d7d3e97f76c702b26d))
* 修复了使用拼音输入法在 safari 上光标位置没有正常更新的问题 ([cb4cf12](https://github.com/wangeditor-team/wangEditor/commit/cb4cf12bcb6448e5964c47674281f37db96069fa))
* 修复连续输入空格滚动条不滚动的bug ([3bd358d](https://github.com/wangeditor-team/wangEditor/commit/3bd358d83969a53f1ed4f3fd349eb186750f9461))
* 修复内容重复和编辑器内容拖动的一些 bug ([5a9c9d0](https://github.com/wangeditor-team/wangEditor/commit/5a9c9d0b0880dc006180a5c4e5828f54cd1905da))
* 修复行间距过小无效 ([5f13a5b](https://github.com/wangeditor-team/wangEditor/commit/5f13a5b3dc859a45ad25f88ad363f408d23bcee1))
* 修复选中内容中文输入时光标定位问题 ([51596a8](https://github.com/wangeditor-team/wangEditor/commit/51596a8b0b920dc1d1a9e39fff7c3624c0aa6f52))
* 修复用户自定义change事件获取html时tabal报错 ([5204f8e](https://github.com/wangeditor-team/wangEditor/commit/5204f8ebf63abdf8a7093e202411b63ce86c2964))
* 修复在 Chrome 和 Safari 中删除内容时,内联空节点被选中 ([a47c73f](https://github.com/wangeditor-team/wangEditor/commit/a47c73fc5fa008096165d5ac9c55d01f4a6b045b))
* 修复在 Safari 下,即使 contenteditable 元素非聚焦状态,并不会删除所选内容 ([3e8ca3c](https://github.com/wangeditor-team/wangEditor/commit/3e8ca3c86074454a75054e5ded03154f6b6544ea))
* 修复在代码块中中文输入会有多余字符的问题 ([a138c3f](https://github.com/wangeditor-team/wangEditor/commit/a138c3f0a2f25d9f89afb912cff45596f99e6b05))
* 修复在destory可能出现editor not find的问题 ([ce60416](https://github.com/wangeditor-team/wangEditor/commit/ce604165527435952b5ac4b011842714ec8cd5dd))
* 修复Safari上table内空行输入报错的问题 ([dae6dc5](https://github.com/wangeditor-team/wangEditor/commit/dae6dc544f714f195989a05970cb6bf272f6eb8b))
* 修复ua正则不支持100+的问题 ([c488ba0](https://github.com/wangeditor-team/wangEditor/commit/c488ba09183cbfcabef223709464c42fac53aea0))
* 选择图片会滚动 ([d2a8762](https://github.com/wangeditor-team/wangEditor/commit/d2a87629cedc3533e268a31ca822f414082bf48d))
* 选中内容输入中文报错 ([890cc68](https://github.com/wangeditor-team/wangEditor/commit/890cc686e566be68227641d5f31b42de66351126))
* 移除了每个包下的 publishConfig directory 配置 ([16559f0](https://github.com/wangeditor-team/wangEditor/commit/16559f052545c111318be760e64291a521bdcc65))
* 优化插入新文本的滚动交互 ([71131a4](https://github.com/wangeditor-team/wangEditor/commit/71131a4355d24b805052fa9bcf1515432e4351ad))
* 优化当父元素有滚动条,插入新文本的滚动交互 ([9275090](https://github.com/wangeditor-team/wangEditor/commit/9275090399f068db14854f2794b9aab996bee22e))
* 优化了 core 类型声明 ([5b5ee1e](https://github.com/wangeditor-team/wangEditor/commit/5b5ee1ee34300748460cedab6fcd46463820f8ef))
* 优化了 deleteFragment 函数调用传参 ([8d8145c](https://github.com/wangeditor-team/wangEditor/commit/8d8145c5e496a28e2d586722101d217ba1be7079))
* 优化了 normalizeDOMPoint 函数 ([31b9999](https://github.com/wangeditor-team/wangEditor/commit/31b99992bdc5bc2cc239320200da7d5ba7d6cfc0))
* 优化了当编辑失焦编辑区域滚动到顶部的问题 ([ebb966b](https://github.com/wangeditor-team/wangEditor/commit/ebb966bce81023c79727bae846920323f733008d))
* 优化了浏览器是否支持 beforeinput 事件的兼容性判断 ([ea221bb](https://github.com/wangeditor-team/wangEditor/commit/ea221bb3e176ace7a99854673fd727dedc0b3ba7))
* 优化选中代码块不应该展示 hoverbar 的交互 ([33dcbd6](https://github.com/wangeditor-team/wangEditor/commit/33dcbd6560dccfbe77e18cfbce8c9f077f19f6cd))
* 在移动 word 之前折叠展开选区 ([6b9b0f3](https://github.com/wangeditor-team/wangEditor/commit/6b9b0f3c9755c1950b0645c34166bd043a9d05f0))
* 增加 EXTEND_CONF 配置扩展能力 ([ff75a16](https://github.com/wangeditor-team/wangEditor/commit/ff75a16643b26d2d0e7a92cfdd827d5f0f56a849))
* 重复创建 ([3682c53](https://github.com/wangeditor-team/wangEditor/commit/3682c53b181b89d2c16b5d9845b381a4813c9e3c))
* autoFucos ([fea2faf](https://github.com/wangeditor-team/wangEditor/commit/fea2faf0af83a3eec67ee7bc7d76328409d2d703))
* beforeinput support ([60e6efc](https://github.com/wangeditor-team/wangEditor/commit/60e6efc3b3d6c31c4834e3b40e02fc8bc4ceaea6))
* blockquote & header insertBreak ([06678c9](https://github.com/wangeditor-team/wangEditor/commit/06678c963e8c8421ecded448de7510b254117550))
* button 增加 type ([37b3390](https://github.com/wangeditor-team/wangEditor/commit/37b33903e0ae5ffe95ab907791ab484facd052d9))
* chrome 链接后输入拼音,js 错误 ([6c04fab](https://github.com/wangeditor-team/wangEditor/commit/6c04fabb2c5ec78e13c1e1583685cf726887dcae))
* clear API ([c188b56](https://github.com/wangeditor-team/wangEditor/commit/c188b567379ae32abcfa879620c995c8d45818c4))
* code-block 选择语言 - 点击拖拽滚动条 ([b8c75e7](https://github.com/wangeditor-team/wangEditor/commit/b8c75e7dc5332c9da622433380802886dedc4344))
* composition-end ([082561d](https://github.com/wangeditor-team/wangEditor/commit/082561dc341b45791933757e2cf6102190004674))
* create - 判断 content length ([c0eadc9](https://github.com/wangeditor-team/wangEditor/commit/c0eadc9bf03edc7576c1d3e957babede4c0b546f))
* dangerouslyInsertHtml - 兼容异常情况 ([8b549f4](https://github.com/wangeditor-team/wangEditor/commit/8b549f480434782107eda3412bf6530d0d7eb9ba))
* droplist 过长 ([1de2a76](https://github.com/wangeditor-team/wangEditor/commit/1de2a76ac802b80c1b45537c129e5833b4d73d33))
* dropPanel 定位 ([e76310a](https://github.com/wangeditor-team/wangEditor/commit/e76310a1c6d4aafb2385faebb005bdddd38f9838))
* editor.blur() api 无效 ([48cbff3](https://github.com/wangeditor-team/wangEditor/commit/48cbff3142d961ff2eaf2f76a3182488de2e5b93))
* firefox下全选输入出现多余字符 ([659b107](https://github.com/wangeditor-team/wangEditor/commit/659b1078e3395ff00ddc0d1792fbf9c4d448ca41))
* fix https://github.com/wangeditor-team/wangEditor-v5/issues/457 ([1d8a46a](https://github.com/wangeditor-team/wangEditor/commit/1d8a46a1b5402c2ecb418db24d9d22532d152cea))
* fullScreen 隐藏 hoverbar ([ec463d3](https://github.com/wangeditor-team/wangEditor/commit/ec463d302cdc527987741ae6208a625af91ea61c))
* getElems 增加 id ([1dcedd9](https://github.com/wangeditor-team/wangEditor/commit/1dcedd9392d2eecef29f9c93e8915a2f2f83b8a5))
* getHtml 死循环 ([4614bfb](https://github.com/wangeditor-team/wangEditor/commit/4614bfb5c3a2658348a59749dd800a349e6c33a9))
* getHtml API ([c0b60cf](https://github.com/wangeditor-team/wangEditor/commit/c0b60cf47d8eaae4292265906fbe07875e1564c9))
* group-menu 考虑 excludeKeys ([ecc29f3](https://github.com/wangeditor-team/wangEditor/commit/ecc29f3b24992c8dc0adf006d81b0d4a252683c5))
* hotkey mod ([d480c20](https://github.com/wangeditor-team/wangEditor/commit/d480c206fd83ecc8d12f36147c210208aa6d6ab3))
* hoverbar - 处于网页下部 ([6cfb3e2](https://github.com/wangeditor-team/wangEditor/commit/6cfb3e2d364f4532cbafe5c8c6e4b3bc13fa2d78))
* hoverbar 被点击多次隐藏 ([bf4fc19](https://github.com/wangeditor-team/wangEditor/commit/bf4fc193847e8caba3a67c8dd152eae4f1950c4f))
* hoverbar active ([ceb3f41](https://github.com/wangeditor-team/wangEditor/commit/ceb3f41deafd8fc2cb8d3e8a498cb8d90ad1c73f))
* hoverbar modal 重复创建 ([70d2b61](https://github.com/wangeditor-team/wangEditor/commit/70d2b618a0662c88cd5e6691f513009726ce1b9b))
* hoverbar show/hide ([c96bc83](https://github.com/wangeditor-team/wangEditor/commit/c96bc8378939fecd78807fea4f2b7e1eec2a9ea0))
* hoverbarKeys - text ([59b4840](https://github.com/wangeditor-team/wangEditor/commit/59b48406b4c373ef029a5f5bdb0d15d925a91a0f))
* html 特殊字符 ([b3eb81b](https://github.com/wangeditor-team/wangEditor/commit/b3eb81bc9c4aa15c2ff7451c173de15d6c4552bc))
* i18n - 获取多语言配置 ([9f81597](https://github.com/wangeditor-team/wangEditor/commit/9f815970f8c3c6dddb6bf846ecb672325e80444b))
* i18n 切换语言 ([b3b4642](https://github.com/wangeditor-team/wangEditor/commit/b3b4642c6e72ab0b13b05657745abb87e71c633d))
* insertHtml - maxLength ([8c7dc8b](https://github.com/wangeditor-team/wangEditor/commit/8c7dc8b8efe1705af9989b040b04e2f98932cb77))
* insertHtml - maxLength ([52d72ec](https://github.com/wangeditor-team/wangEditor/commit/52d72ec4778a7a6c6f31a7e95d82fb91c9384ae8))
* insertHtml - maxLength ([b573359](https://github.com/wangeditor-team/wangEditor/commit/b5733597966b16d876b0c0e18509f04638e1c4df))
* insertKeys ([0a89420](https://github.com/wangeditor-team/wangEditor/commit/0a8942050bd0b39afb5bbc55ca7842461a5b98eb))
* link, text hoverbar 选区问题 ([e0b7438](https://github.com/wangeditor-team/wangEditor/commit/e0b7438c89a347f1b0b940d9c11150b72d595529))
* maxLength - 拼音 + 粘贴 ([3ac4db6](https://github.com/wangeditor-team/wangEditor/commit/3ac4db6d78cbe7a8d1fe19747deb0a17edd9b552))
* maxLength 对于拼音输入无效 ([117faa6](https://github.com/wangeditor-team/wangEditor/commit/117faa635e99667c4762b58757f045c80f949323))
* menu 点击多次才能生效 ([6497e39](https://github.com/wangeditor-team/wangEditor/commit/6497e39225a993c4d87f9ffddf20086446a4fbc2))
* min-height ([460fad5](https://github.com/wangeditor-team/wangEditor/commit/460fad56001e83842786629b1d1f8ed6411f4fd4))
* modal close ([dbfb3b4](https://github.com/wangeditor-team/wangEditor/commit/dbfb3b42504ae97aa0f641ff7fe5eba208b43580))
* normalize when create editor ([2b51962](https://github.com/wangeditor-team/wangEditor/commit/2b5196244a93ad7beb316bfa42e557221967d063))
* parse html - 有些 elem children 需要过滤 ([63cbb80](https://github.com/wangeditor-team/wangEditor/commit/63cbb804c8c7a778a4ee1f4ba8717a11b4b6b5a3))
* parse-html - space 160 ([54e72bc](https://github.com/wangeditor-team/wangEditor/commit/54e72bcb5ed38b8dc77e957ebd5d35881466b5b3))
* parse-html - sub sup ([2c15a5f](https://github.com/wangeditor-team/wangEditor/commit/2c15a5f9c9c2de8b34770a6bebfe765d203a03f6))
* parseHtml - 多空格文本 ([5d4479c](https://github.com/wangeditor-team/wangEditor/commit/5d4479c5d11fc23233ea63f0b69c845fa2ab8630))
* placeholder - 全选输入中文 ([fe4dd2a](https://github.com/wangeditor-team/wangEditor/commit/fe4dd2a85d54d64e2411c3dfc6cb90ac18003e28))
* placeHolder elem ([7d577ac](https://github.com/wangeditor-team/wangEditor/commit/7d577ac4d6003d1b4c8575be1c014cfa6632d248))
* readOnly 时菜单还可操作 ([0d4a29b](https://github.com/wangeditor-team/wangEditor/commit/0d4a29bb5ba8b62ac11a09d3f814abcb1fcf46be))
* readOnly 依然可以 insertText ([096eeaf](https://github.com/wangeditor-team/wangEditor/commit/096eeafd0fc62edf196ed3a9549c04ce19b6b159))
* rename es module filename ([1821d4e](https://github.com/wangeditor-team/wangEditor/commit/1821d4eef49e64efcb41b848849ca7a5e6472044))
* shadowDOM 节点支持问题 ([5eb41f1](https://github.com/wangeditor-team/wangEditor/commit/5eb41f1048ad110003b2ef95e0f22e26b7fd757c))
* shadowDOM 在失焦状态下元素获取失败 ([98aeccc](https://github.com/wangeditor-team/wangEditor/commit/98aeccc5be85513d577397642a9a2d2f730a0406))
* table - 粘贴合并单元格的表格 ([56ecb63](https://github.com/wangeditor-team/wangEditor/commit/56ecb6392510d433e092653f0f08183361778a3d))
* table - elemToHtml ([e36e609](https://github.com/wangeditor-team/wangEditor/commit/e36e6092ef721723169afc8bf0560a47ac9f4dfc))
* table-cell 全选 ([1ef4872](https://github.com/wangeditor-team/wangEditor/commit/1ef48729e6d99e7414bc89bc4ef0d66c172fc566))
* tableCell 中 br 报错 ([8604db7](https://github.com/wangeditor-team/wangEditor/commit/8604db751b622c01fa5391af59328236cf13effc))
* td th 中换行不起作用 ([89c6032](https://github.com/wangeditor-team/wangEditor/commit/89c6032a1c41100b7adaf9927e6bc9c06d0228db))
* textarea height ([873b04a](https://github.com/wangeditor-team/wangEditor/commit/873b04a65a7140afdc2427ac07fce57b3e2c423e))
* tooltip ([7e066d1](https://github.com/wangeditor-team/wangEditor/commit/7e066d1368f1bfaaca21e3385647be2dee6837f9))
* upload progress 0 ([9e660be](https://github.com/wangeditor-team/wangEditor/commit/9e660be126adb969dd8a80166b60d6f62be17b2a))
* url 后面中文输入异常 ([3bcebc7](https://github.com/wangeditor-team/wangEditor/commit/3bcebc78352e05cfec92eed92ee0b05d233feaef))
* void node - 不清理 text ([1bc891c](https://github.com/wangeditor-team/wangEditor/commit/1bc891c46318f5c5ab969752b3ddb8d75ee1faf7))
* vue 组件增加 customPaste ([e764248](https://github.com/wangeditor-team/wangEditor/commit/e76424870c75e09ab6267b604a951444b2e847c5))
* w-e-menu-tooltip 和 v4 冲突 ([762403b](https://github.com/wangeditor-team/wangEditor/commit/762403b2c4e860b3855cbc0caa883b1443d3c862))
* z-index ([02ec2d5](https://github.com/wangeditor-team/wangEditor/commit/02ec2d54605e747b7d4e1377a58fc9e14c9bba7c))
### Features
* 增加 API ([63d6fe8](https://github.com/wangeditor-team/wangEditor/commit/63d6fe85f17fea31c95fec727126799a979ec2f9))
* 增加 enable disable API(删除 setConfig setMenuConfig API) ([984fc50](https://github.com/wangeditor-team/wangEditor/commit/984fc50520061fc34ea08f4136bdeb93dee46564))
* 支持 nodejs 环境 ([484f18c](https://github.com/wangeditor-team/wangEditor/commit/484f18c3abc70d19e51c556f48491c18d390b1e1))
* API - getElemsByType + move + moveReverse ([748ad71](https://github.com/wangeditor-team/wangEditor/commit/748ad710b55d26ade4df1d8caa0a6ea5d2f6f8c7))
* basic text paste ([f0a5b98](https://github.com/wangeditor-team/wangEditor/commit/f0a5b980c95fa1e2fc59a898c6e0d0723c276c28))
* basic text style module ([005b343](https://github.com/wangeditor-team/wangEditor/commit/005b343573ba98f2d0b8480d034ff6807a499aa3))
* bold & header ([8130c23](https://github.com/wangeditor-team/wangEditor/commit/8130c23ad84485a68cf9ca4b53d52fab1cec4e96))
* clear color ([93b1a18](https://github.com/wangeditor-team/wangEditor/commit/93b1a189395ba113dfe9f793c69e136607f9a28f))
* clear editor api ([01b07f2](https://github.com/wangeditor-team/wangEditor/commit/01b07f2a2250661ef121919192d40a4852d50a91))
* clearStyle menu ([8002f70](https://github.com/wangeditor-team/wangEditor/commit/8002f707ed04b914180ec36fdca0edf48c815e01))
* close modal ([b5106f4](https://github.com/wangeditor-team/wangEditor/commit/b5106f4428813cf794c468034c80824b0a4f08db))
* code highlight ([42b2f8d](https://github.com/wangeditor-team/wangEditor/commit/42b2f8d192e2433593c11ad0b8424737f6cffb58))
* code-block - part ([a8bcd63](https://github.com/wangeditor-team/wangEditor/commit/a8bcd63d882832ac05a32878df0f767d145e0fa7))
* create editor ([12d98e4](https://github.com/wangeditor-team/wangEditor/commit/12d98e4bee179e9d277ec3ec2ecb827962ed0e75))
* customPaste ([0f25f5c](https://github.com/wangeditor-team/wangEditor/commit/0f25f5cae3a2cd5ae5832f3fc1026b3ab6d047e0))
* dangerouslyInsertHtml ([4dc3d0c](https://github.com/wangeditor-team/wangEditor/commit/4dc3d0cb403d751ae067a541868e77083c8ce74c))
* drag resize image ([cd72028](https://github.com/wangeditor-team/wangEditor/commit/cd72028f1786e2e53079ad5cbef1b8569731ca79))
* editor 生命周期,自定义事件 ([00e9bc2](https://github.com/wangeditor-team/wangEditor/commit/00e9bc2cfcb8b622764db1c76394491d72ffd93e))
* editor with-selection plugin ([9f0a39f](https://github.com/wangeditor-team/wangEditor/commit/9f0a39fecf6d92888d2a97929820d3be038efb31))
* editor.alert ([f147c8f](https://github.com/wangeditor-team/wangEditor/commit/f147c8f234510959c770860ac2f194e8d720f177))
* editor.isSelectedAll ([960c845](https://github.com/wangeditor-team/wangEditor/commit/960c8455f85a6bc7350f9944be80b3997bc1fea1))
* editor.showProgressBar ([51761d4](https://github.com/wangeditor-team/wangEditor/commit/51761d466ab3ef7c99e872954d4724ab51d8e28c))
* focus支持focus到文档末尾 ([628830e](https://github.com/wangeditor-team/wangEditor/commit/628830ef06ff85b3e67001ce30dd9e0557b0aa28))
* font-size + font-family ([cc649e0](https://github.com/wangeditor-team/wangEditor/commit/cc649e0918ce58e78b4d5ee49a400197b9d04b70))
* fullScreen ([e7ccd88](https://github.com/wangeditor-team/wangEditor/commit/e7ccd88a7dd58f64b7bd484de428e3a76cc994f7))
* getElemsByTypePrefix (删掉 getHeaders) ([c18834b](https://github.com/wangeditor-team/wangEditor/commit/c18834b3ebfd97fb36ccbe0faa84e6fe8c30eb67))
* getHeaders & editor.srcollToElem ([2bfb813](https://github.com/wangeditor-team/wangEditor/commit/2bfb813e4957f080c6676ec38f8f051275cdf44a))
* getSelectionText + maxLength ([58f6648](https://github.com/wangeditor-team/wangEditor/commit/58f66489b65f857238d96b93120f6de7e2750c81))
* groupButton disabled ([8ffd44c](https://github.com/wangeditor-team/wangEditor/commit/8ffd44c9a44758e951ca7bd02dd46746fcac1c03))
* hover bar ([107356e](https://github.com/wangeditor-team/wangEditor/commit/107356eff7bfaf53ce25e39244f8133c80518375))
* i18n ([c11b244](https://github.com/wangeditor-team/wangEditor/commit/c11b2440f91b99d40bca18b675c66a22b6e160c9))
* image menu - width 50% 100% ([f9b4c68](https://github.com/wangeditor-team/wangEditor/commit/f9b4c68dff3232b50491b07949c20eb4c18baa6b))
* image menu config ([bb18774](https://github.com/wangeditor-team/wangEditor/commit/bb187740e9703b4a76cde4f5e4d32ac714aa793a))
* image menus & position ([bf5beba](https://github.com/wangeditor-team/wangEditor/commit/bf5beba7b3014d63f0b9fe0063530c8b101a5011))
* indent menu + groupMenu ([08db901](https://github.com/wangeditor-team/wangEditor/commit/08db901cd3a3f2ddb2173cc4b36d471e4e68237e))
* insert link ([b04242f](https://github.com/wangeditor-team/wangEditor/commit/b04242ffa252d4088f5360c3de45c24d6f493552))
* list menu ([fe6c083](https://github.com/wangeditor-team/wangEditor/commit/fe6c0830b2c43e335e5972f85096f490694bbe19))
* menu color - part ([3a6cc86](https://github.com/wangeditor-team/wangEditor/commit/3a6cc86a7f9133d0862310c408abafb30c531734))
* menu color & dropPanel & menu config ([5d0d41b](https://github.com/wangeditor-team/wangEditor/commit/5d0d41b9a765a7deb583393f129925414c36ef35))
* menu hotkey ([fee05f1](https://github.com/wangeditor-team/wangEditor/commit/fee05f189434d1e57a32ff0dea1a57db6830318a))
* modal appendTo body ([fc0ab06](https://github.com/wangeditor-team/wangEditor/commit/fc0ab06d5c7177eceb04643234a8c301ca4de396))
* onBlur onFocus ([590ab4a](https://github.com/wangeditor-team/wangEditor/commit/590ab4a990048bb22cf15787a5fd4615db5b9ef6))
* parse html ([2a5eace](https://github.com/wangeditor-team/wangEditor/commit/2a5eace00f33cded50b68e8164748ec2480213fd))
* placeholder ([a3e4cdc](https://github.com/wangeditor-team/wangEditor/commit/a3e4cdcd474063e4f436327aaf4074bb2126d941))
* react 组件 ([448fc83](https://github.com/wangeditor-team/wangEditor/commit/448fc838d64dbef52cbcddde0e98eb021d8a9122))
* scroll config ([b4942b4](https://github.com/wangeditor-team/wangEditor/commit/b4942b4334f255b3d537389be3dacf1642dd5441))
* selectList ([b7366ab](https://github.com/wangeditor-team/wangEditor/commit/b7366ab2dafd379145d85881052d6f400bd13c85))
* text and toolbar ([3ae5d0c](https://github.com/wangeditor-team/wangEditor/commit/3ae5d0c4138fec7397ac8629e0012affe6b7dfa4))
* toHtml 机制 ([1c4d872](https://github.com/wangeditor-team/wangEditor/commit/1c4d8729f84aaab6a448f23064b34a20596305e9))
* toolbar config - insertKeys ([a2f3c4b](https://github.com/wangeditor-team/wangEditor/commit/a2f3c4be3762831723495bbc9d50eb6c9b05d195))
* toolbar excludeKeys ([09bd196](https://github.com/wangeditor-team/wangEditor/commit/09bd196ea24c19b04e5e7e38227ca94332847bf8))
* tooltip ([994d875](https://github.com/wangeditor-team/wangEditor/commit/994d875fee81cf01271c2e440c1df202aa067d0e))
* updateLink + unLink + viewLink ([254d554](https://github.com/wangeditor-team/wangEditor/commit/254d55466b3c8527dd9f0bf34681abd801c8c8ce))
* vue2 组件 ([fd7847a](https://github.com/wangeditor-team/wangEditor/commit/fd7847a72db661bbf29cf636d454c075fd331224))
================================================
FILE: packages/core/README.md
================================================
# wangEditor core
[wangEditor](https://www.wangeditor.com/) core.
## Main Functionalities
- View( model -> vdom -> DOM ) + Selection
- Menus + toolbar + hoverbar
- Core editor APIs and events
- Register third-party modules (menus, plugins...)
## Main dependencies
- [slate.js](https://docs.slatejs.org/)
- [snabbdom.js](https://github.com/snabbdom/snabbdom)
================================================
FILE: packages/core/__tests__/config/editor-config.test.ts
================================================
/**
* @description editor config test
* @author wangfupeng
*/
import { Editor } from 'slate'
import createCoreEditor from '../create-core-editor' // packages/core 不依赖 packages/editor ,不能使用后者的 createEditor
describe('editor config', () => {
function getStartLocation(editor) {
return Editor.start(editor, [])
}
it('if set placeholder option, it will show placeholder element when editor content is empty', () => {
const container = document.createElement('div')
createCoreEditor({
selector: container,
config: {
placeholder: 'editor placeholder',
},
})
const el = container.querySelector('.w-e-text-placeholder')
expect(el!.textContent).toBe('editor placeholder')
})
it('if set placeholder option, it will hide placeholder element when editor content is not empty', () => {
const container = document.createElement('div')
createCoreEditor({
selector: container,
config: {
placeholder: 'editor placeholder',
},
content: [{ type: 'paragraph', children: [{ text: '123' }] }],
})
const el = container.querySelector('.w-e-text-placeholder')
expect(el).toBeNull()
})
it('if set readOnly option, isDisabled return true', () => {
const editor = createCoreEditor({
config: {
readOnly: true,
},
})
expect(editor.isDisabled()).toBeTruthy()
})
it('if set readOnly option, can not insert text to editor', () => {
const editor = createCoreEditor({
config: {
readOnly: true,
},
})
editor.select(getStartLocation(editor))
editor.insertText('xxx') // readOnly 时无法插入文本
expect(editor.getText()).toBe('')
})
it('if set maxLength option, the editor can not update content when text length is equal to maxLength', done => {
const editor = createCoreEditor({
config: {
maxLength: 10,
onMaxLength: () => {
done() // 触发回调,才能完成该测试
},
},
})
editor.select(getStartLocation(editor))
// 插入 9 个字符,小于 maxLength
editor.insertText('123456789')
expect(editor.getText()).toBe('123456789')
// 再插入其他字符,则只能插入一个
editor.insertText('abc')
expect(editor.getText()).toBe('123456789a')
})
it('if set onCreated option, it will be called when created editor', done => {
const fn = jest.fn()
createCoreEditor({
config: {
onCreated: fn,
},
})
setTimeout(() => {
expect(fn).toHaveBeenCalled()
done()
})
})
it('if set onChange option, it will be called when change editor selection', done => {
const fn = jest.fn()
const editor = createCoreEditor({
config: {
onChange: fn,
},
})
editor.select(getStartLocation(editor)) // 选区变化,触发 onchange
setTimeout(() => {
expect(fn).toHaveBeenCalledWith(editor)
done()
})
})
it('if set onChange option, it will be called when change editor content', done => {
const fn = jest.fn()
const editor = createCoreEditor({
config: {
onChange: fn,
},
})
editor.select(getStartLocation(editor))
// 避免选区干扰
setTimeout(() => {
editor.insertText('123')
}, 50)
setTimeout(() => {
expect(fn).toHaveBeenCalledTimes(2)
done()
}, 80)
})
it('if set onDestroyed option, it will be called when destroy editor', done => {
const fn = jest.fn()
const editor = createCoreEditor({
config: {
onDestroyed: fn,
},
})
setTimeout(() => {
editor.destroy()
})
setTimeout(() => {
expect(fn).toHaveBeenCalledWith(editor)
done()
}, 20)
})
})
================================================
FILE: packages/core/__tests__/config/menu-config.test.ts
================================================
/**
* @description menu config test
* @author wangfupeng
*/
import createCoreEditor from '../create-core-editor' // packages/core 不依赖 packages/editor ,不能使用后者的 createEditor
import { registerGlobalMenuConf } from '../../src/config/register'
describe('menu config', () => {
it('set and get', () => {
// 先注册一下菜单 key ,再设置配置(专为单元测试,用户使用时不涉及)
registerGlobalMenuConf('bold', {})
const menuKey = 'bold' // 必须是一个存在的 menu key
const menuConfig = {
x: 100,
}
const editor = createCoreEditor({
config: {
MENU_CONF: {
[menuKey]: menuConfig,
},
},
})
expect(editor.getMenuConfig(menuKey)).toEqual(menuConfig)
})
})
================================================
FILE: packages/core/__tests__/config/toolbar-config.test.ts
================================================
/**
* @description toolbar config test
* @author wangfupeng
*/
import createCoreEditor from '../create-core-editor'
import { IDomEditor } from '../../src/editor/interface'
import createToolbarForSrc from '../../src/create/create-toolbar'
// 注册几个菜单,测试用
import '../menus/register-menus/index'
// 创建 toolbar
function createToolbar(editor: IDomEditor, customConfig = {}) {
const container = document.createElement('div')
document.body.appendChild(container)
return createToolbarForSrc(editor, {
selector: container,
config: {
toolbarKeys: ['myButtonMenu', 'mySelectMenu', 'myModalMenu'], // 已注册的菜单 key
...customConfig,
},
})
}
describe('toolbar config', () => {
const editor = createCoreEditor()
it('default config', () => {
const toolbar = createToolbar(editor)
const defaultConfig = toolbar.getConfig()
const { excludeKeys = [], toolbarKeys = [] } = defaultConfig
expect(excludeKeys.length).toBe(0)
expect(toolbarKeys.length).toBeGreaterThan(0)
})
it('toolbarKeys', () => {
const keys = ['mySelectMenu', 'myModalMenu']
const toolbar = createToolbar(editor, {
toolbarKeys: keys,
})
const { toolbarKeys = [] } = toolbar.getConfig()
expect(toolbarKeys).toEqual(keys)
})
it('excludeKeys', () => {
const keys = ['myButtonMenu', 'mySelectMenu']
const toolbar = createToolbar(editor, {
excludeKeys: keys,
})
const { excludeKeys = [] } = toolbar.getConfig()
expect(excludeKeys).toEqual(keys)
})
it('insertKeys', () => {
const toolbarKeys = ['mySelectMenu', 'myModalMenu']
const insertKeysInfo = {
index: 0,
keys: ['myButtonMenu'],
}
const toolbar = createToolbar(editor, {
toolbarKeys,
insertKeys: insertKeysInfo,
})
const { insertKeys = {} } = toolbar.getConfig()
expect(insertKeys).toEqual(insertKeysInfo)
})
})
================================================
FILE: packages/core/__tests__/create/content-to-html.test.ts
================================================
/**
* @description convert to html test
* @author wangfupeng
*/
import createEditor from '../../src/create/create-editor'
describe('convert to html or text', () => {
let container = document.createElement('div')
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
})
afterEach(() => {
document.body.removeChild(container)
})
it('convert to html if give selector option', () => {
const editor = createEditor({
selector: container,
content: [{ type: 'paragraph', children: [{ text: 'hello' }] }],
})
expect(editor.getHtml()).toBe('hello')
})
it('convert to html if not give selector option', () => {
const editor = createEditor({
// 不传入 selector ,只有 content
content: [{ type: 'paragraph', children: [{ text: 'hello' }] }],
})
expect(editor.getHtml()).toBe('hello')
})
it('convert to text if give selector option', () => {
const editor = createEditor({
selector: container,
content: [
{ type: 'paragraph', children: [{ text: 'hello' }] },
{ type: 'paragraph', children: [{ text: 'world' }] },
],
})
expect(editor.getText()).toBe('hello\nworld')
})
it('convert to text if not give selector option', () => {
const editor = createEditor({
// 不传入 selector ,只有 content
content: [
{ type: 'paragraph', children: [{ text: 'hello' }] },
{ type: 'paragraph', children: [{ text: 'world' }] },
],
})
expect(editor.getText()).toBe('hello\nworld')
})
})
================================================
FILE: packages/core/__tests__/create-core-editor.ts
================================================
/**
* @description create editor - 用于 packages/core 单元测试
* @author wangfupeng
*/
import createEditor from '../src/create/create-editor'
export default function (options: any = {}) {
const container = document.createElement('div')
document.body.appendChild(container)
return createEditor({
selector: container,
...options,
})
}
================================================
FILE: packages/core/__tests__/editor/dom-editor.test.ts
================================================
/**
* @description core editor test
* @author luochao
*/
import { Editor, Range as SlateRange } from 'slate'
import { DomEditor } from '../../src/editor/dom-editor'
import { IDomEditor } from '../../src/editor/interface'
import createCoreEditor from '../create-core-editor' // packages/core 不依赖 packages/editor ,不能使用后者的 createEditor
import { Key } from '../../src/utils/key'
import { NODE_TO_KEY } from '../../src/utils/weak-maps'
let editor: IDomEditor
describe('Core DomEditor', () => {
function genStartLocation() {
return Editor.start(editor, [])
}
beforeEach(() => {
editor = createCoreEditor()
editor.select(genStartLocation())
})
afterEach(() => {
editor.destroy()
})
test('DomEditor getWindow should throw Error', () => {
try {
DomEditor.getWindow(editor)
} catch (err) {
expect(err.message).toBe('Unable to find a host window element for this editor')
}
})
test('DomEditor findKey should return Key for a node', () => {
editor.apply({
type: 'insert_text',
path: [0, 0],
text: 'test123',
offset: 0,
})
const node = editor.children[0]
expect(DomEditor.findKey(editor, node) instanceof Key).toBeTruthy()
})
test('DomEditor findKey should return unique Key for different node', () => {
editor.apply({
type: 'insert_node',
path: [0, 0],
node: {
type: 'paragraph',
children: [{ text: 'test123' }],
},
})
editor.apply({
type: 'insert_node',
path: [0, 1],
node: {
type: 'header1',
children: [{ text: 'test456' }],
},
})
const [node1, node2] = (editor.children[0] as any).children
const keyId1 = DomEditor.findKey(editor, node1).id
const keyId2 = DomEditor.findKey(editor, node2).id
expect(keyId1).not.toBe(keyId2)
})
test('DomEditor findKey should generate new key if node do not exist in NODE_TO_KEY', () => {
const node = {
type: 'header2',
children: [{ text: '123' }],
}
// 防卫断言
expect(NODE_TO_KEY.get(node)).toBeUndefined()
const newKey = DomEditor.findKey(editor, node)
expect(NODE_TO_KEY.get(node)).toEqual(newKey)
})
test('DomEditor setNewKey should set new value to NODE_TO_KEY', () => {
const node = {
type: 'header2',
children: [{ text: '123' }],
}
expect(NODE_TO_KEY.get(node)).toBeUndefined()
DomEditor.setNewKey(node)
expect(NODE_TO_KEY.get(node)).not.toBeUndefined()
})
test('findPath', () => {
const p = editor.children[0]
// @ts-ignore
const textNode = p.children[0]
const path = DomEditor.findPath(null, textNode)
expect(path).toEqual([0, 0])
})
test('findDocumentOrShadowRoot', () => {
const doc = DomEditor.findDocumentOrShadowRoot(editor)
expect(doc).toBe(document)
})
test('getParentNode', () => {
const p = editor.children[0]
// @ts-ignore
const textNode = p.children[0]
expect(DomEditor.getParentNode(null, textNode)).toBe(p)
expect(DomEditor.getParentNode(null, p)).toBe(editor)
})
test('getParentsNodes', () => {
const p = editor.children[0]
// @ts-ignore
const textNode = p.children[0]
const parents = DomEditor.getParentsNodes(editor, textNode)
expect(parents[0]).toBe(p)
expect(parents[1]).toBe(editor)
})
test('getTopNode', () => {
const p = editor.children[0]
// @ts-ignore
const textNode = p.children[0]
const topNode = DomEditor.getTopNode(editor, textNode)
expect(topNode).toBe(p)
})
test('toDOMNode', () => {
const p = editor.children[0]
const key = DomEditor.findKey(editor, p)
const domNode = DomEditor.toDOMNode(editor, p)
expect(domNode.tagName).toBe('DIV')
expect(domNode.id).toBe(`w-e-element-${key.id}`)
})
test('hasDOMNode', () => {
const p = editor.children[0]
const domNode = DomEditor.toDOMNode(editor, p)
const res = DomEditor.hasDOMNode(editor, domNode)
expect(res).toBeTruthy()
})
// TODO 待写...
// test('toDOMRange', () => {})
// TODO 待写...
// test('toDOMPoint', () => {})
test('toSlateNode', () => {
const p = editor.children[0]
const domNode = DomEditor.toDOMNode(editor, p)
const slateNode = DomEditor.toSlateNode(null, domNode)
expect(slateNode).toBe(p)
})
// TODO 待写...
// test('findEventRange', () => {})
// TODO 待写...
// test('toSlateRange', () => {})
// TODO 待写...
// test('toSlatePoint', () => {})
test('hasRange', () => {
editor.insertText('hello')
editor.selectAll()
const res = DomEditor.hasRange(editor, editor.selection as SlateRange)
expect(res).toBeTruthy()
// expect(1).toBe(1)
})
test('getNodeType', () => {
const p = editor.children[0]
// @ts-ignore
const textNode = p.children[0]
expect(DomEditor.getNodeType(p)).toBe('paragraph')
expect(DomEditor.getNodeType(textNode)).toBe('')
})
test('checkNodeType', () => {
const p = editor.children[0]
expect(DomEditor.checkNodeType(p, 'paragraph')).toBeTruthy()
})
test('getSelectedElems', () => {
editor.insertNode({
type: 'some-elem',
children: [{ text: 'hello' }],
})
editor.selectAll()
const selectedElems = DomEditor.getSelectedElems(editor)
expect(selectedElems.length).toBe(2)
expect(selectedElems[1].type).toBe('some-elem')
})
test('getSelectedNodeByType', () => {
const p = editor.children[0]
const selectedNode = DomEditor.getSelectedNodeByType(editor, 'paragraph')
expect(selectedNode).toBe(p)
})
test('getSelectedTextNode', () => {
const p = editor.children[0]
// @ts-ignore
const textNode = p.children[0]
const selectedTextNode = DomEditor.getSelectedTextNode(editor)
expect(selectedTextNode).toBe(textNode)
})
test('isNodeSelected', () => {
const p = editor.children[0]
// @ts-ignore
const textNode = p.children[0]
expect(DomEditor.isNodeSelected(editor, p)).toBeTruthy()
expect(DomEditor.isNodeSelected(editor, textNode)).toBeTruthy()
})
test('isSelectionAtLineEnd', () => {
editor.insertText('hello')
expect(DomEditor.isSelectionAtLineEnd(editor, [0])).toBeTruthy() // 在第一行的末尾
editor.select(genStartLocation()) // 选中开始
expect(DomEditor.isSelectionAtLineEnd(editor, [0])).toBeFalsy() // 在第一行的开头
})
})
================================================
FILE: packages/core/__tests__/editor/plugins/with-config.test.ts
================================================
/**
* @description config API test
* @author wangfupeng
*/
import createCoreEditor from '../../create-core-editor' // packages/core 不依赖 packages/editor ,不能使用后者的 createEditor
import { withConfig } from '../../../src/editor/plugins/with-config'
function createEditor(...args) {
return withConfig(createCoreEditor(...args))
}
describe('editor config API', () => {
it('get config', () => {
const editor = createEditor()
const defaultConfig = editor.getConfig()
expect(defaultConfig).not.toBeNull()
expect(defaultConfig.autoFocus).toBeTruthy()
expect(defaultConfig.readOnly).toBeFalsy()
// 其他 props 不一一写了
})
it('get menu config', () => {
const editor = createEditor()
const insertLinkConfig = editor.getMenuConfig('insertLink')
expect(insertLinkConfig).not.toBeNull()
})
it('get all menus', () => {
const editor = createEditor()
const menuKeys = editor.getAllMenuKeys()
expect(Array.isArray(menuKeys)).toBeTruthy()
})
})
================================================
FILE: packages/core/__tests__/editor/plugins/with-content.test.ts
================================================
/**
* @description content API test
* @author wangfupeng
*/
import { Editor, Transforms, Node, Selection } from 'slate'
import createCoreEditor from '../../create-core-editor' // packages/core 不依赖 packages/editor ,不能使用后者的 createEditor
import { withContent } from '../../../src/editor/plugins/with-content'
import { IDomEditor } from '../../../src/editor/interface'
function createEditor(...args) {
return withContent(createCoreEditor(...args))
}
let editor: IDomEditor
function setEditorSelection(
editor: IDomEditor,
selection: Selection = {
anchor: { path: [0, 0], offset: 0 },
focus: { path: [0, 0], offset: 0 },
}
) {
editor.selection = selection
}
const ignoreTag = [
'doctype',
'!doctype',
'meta',
'script',
'style',
'link',
'frame',
'iframe',
'title',
'svg',
]
describe('editor content API', () => {
function getStartLocation(editor) {
return Editor.start(editor, [])
}
it('handleTab', () => {
const editor = createEditor()
editor.select(getStartLocation(editor))
editor.handleTab()
expect(editor.getText().length).toBe(4) // 默认 tab 键,输入 4 空格
})
it('getHtml', () => {
const editor = createEditor({
content: [{ type: 'paragraph', children: [{ text: 'hello' }] }],
})
const html = editor.getHtml()
expect(html).toBe('hello')
})
it('getHtml with void element', () => {
const editor = createEditor({
content: [
{ type: 'paragraph', children: [{ text: 'hello' }] },
{ type: 'image', children: [{ text: '' }], src: 'test.jpg' },
],
})
const html = editor.getHtml()
expect(html).toBe('hello')
})
it('getText', () => {
const editor = createEditor({
content: [
{ type: 'paragraph', children: [{ text: 'hello' }] },
{ type: 'paragraph', children: [{ text: 'world' }] },
],
})
const text = editor.getText()
expect(text).toBe('hello\nworld')
})
it('isEmpty', () => {
const editor1 = createEditor()
expect(editor1.isEmpty()).toBeTruthy()
const editor2 = createEditor({
content: [{ type: 'paragraph', children: [{ text: 'hello' }] }],
})
expect(editor2.isEmpty()).toBeFalsy()
})
it('getSelectionText', () => {
const editor = createEditor({
content: [{ type: 'paragraph', children: [{ text: 'hello' }] }],
})
editor.select(getStartLocation(editor)) // 光标在开始位置
expect(editor.getSelectionText()).toBe('')
editor.select([]) // 全选
expect(editor.getSelectionText()).toBe('hello')
})
it('getElemsByTypePrefix', () => {
const editor = createEditor({
content: [
{ type: 'header1', children: [{ text: 'a' }] },
{ type: 'header2', children: [{ text: 'b' }] },
{ type: 'paragraph', children: [{ text: 'c' }] },
],
})
const headers = editor.getElemsByTypePrefix('header')
expect(headers.length).toBe(2)
const pList = editor.getElemsByTypePrefix('paragraph')
expect(pList.length).toBe(1)
const images = editor.getElemsByTypePrefix('image')
expect(images.length).toBe(0)
})
it('getElemsByType', () => {
const editor = createEditor({
content: [
{ type: 'header1', children: [{ text: 'a' }] },
{ type: 'header2', children: [{ text: 'b' }] },
{ type: 'paragraph', children: [{ text: 'c' }] },
],
})
const headers = editor.getElemsByType('header')
expect(headers.length).toBe(0)
const pList = editor.getElemsByType('paragraph')
expect(pList.length).toBe(1)
const images = editor.getElemsByType('image')
expect(images.length).toBe(0)
})
it('deleteBackward with character', () => {
const editor = createEditor({
content: [{ type: 'paragraph', children: [{ text: 'hello' }] }],
})
editor.select(getStartLocation(editor)) // 光标在开始位置
Transforms.move(editor, { distance: 2, unit: 'character' }) // 光标移动 2 个字符
editor.deleteBackward('character') // 向后删除
expect(editor.getText()).toBe('hllo')
})
it('deleteBackward with word', () => {
const editor = createEditor({
content: [{ type: 'paragraph', children: [{ text: 'hello world' }] }],
})
editor.select(getStartLocation(editor)) // 光标在开始位置
Transforms.move(editor, { distance: 1, unit: 'word' }) // 光标移动 1 个单词
editor.deleteBackward('word') // 向后删除
expect(editor.getText()).toBe(' world')
})
it('deleteForward with character', () => {
const editor = createEditor({
content: [{ type: 'paragraph', children: [{ text: 'hello' }] }],
})
editor.select(getStartLocation(editor)) // 光标在开始位置
Transforms.move(editor, { distance: 1, unit: 'character' }) // 光标移动 1 个字符
editor.deleteForward('character') // 向前删除
expect(editor.getText()).toBe('hllo')
})
it('deleteForward with word', () => {
const editor = createEditor({
content: [{ type: 'paragraph', children: [{ text: 'hello world' }] }],
})
editor.select(getStartLocation(editor)) // 光标在开始位置
Transforms.move(editor, { distance: 1, unit: 'word' }) // 光标移动 1 个 word
editor.deleteForward('word') // 向前删除
expect(editor.getText()).toBe('hello')
})
it('deleteForward with line', () => {
const editor = createEditor({
content: [
{ type: 'paragraph', children: [{ text: 'hello' }] },
{ type: 'paragraph', children: [{ text: 'world' }] },
],
})
editor.select(getStartLocation(editor)) // 光标在开始位置
editor.deleteForward('line') // 向前删除
expect(editor.getText()).toBe('\nworld')
})
it('getFragment', () => {
const editor = createEditor({
content: [{ type: 'paragraph', children: [{ text: 'hello' }] }],
})
// 选中 'hel'lo
editor.select({
anchor: {
path: [0, 0],
offset: 0,
},
focus: {
path: [0, 0],
offset: 3,
},
})
const fragment = editor.getFragment() // 获取选中内容
expect(Node.string(fragment[0])).toBe('hel')
})
it('deleteFragment', () => {
const editor = createEditor({
content: [{ type: 'paragraph', children: [{ text: 'hello' }] }],
})
// 选中 'hel'lo
editor.select({
anchor: {
path: [0, 0],
offset: 0,
},
focus: {
path: [0, 0],
offset: 3,
},
})
editor.deleteFragment() // 删除选中内容
expect(editor.getText()).toBe('lo')
})
it('insertBreak', () => {
const editor = createEditor()
editor.select(getStartLocation(editor)) // 光标在开始位置
editor.insertBreak()
const pList = editor.getElemsByTypePrefix('paragraph')
expect(pList.length).toBe(2)
})
it('insertText', () => {
const editor = createEditor()
editor.select(getStartLocation(editor)) // 光标在开始位置
editor.insertText('xxx')
expect(editor.getText()).toBe('xxx')
})
it('clear', () => {
const editor = createEditor({
content: [{ type: 'paragraph', children: [{ text: 'hello' }] }],
})
editor.clear()
expect(editor.getText()).toBe('')
})
it('undo', () => {
const editor = createEditor()
editor.select(getStartLocation(editor)) // 光标在开始位置
editor.insertText('hello')
// @ts-ignore
editor.undo()
expect(editor.getText()).toBe('')
})
it('redo', () => {
const editor = createEditor()
editor.select(getStartLocation(editor)) // 光标在开始位置
editor.insertText('hello')
// @ts-ignore
editor.undo()
// @ts-ignore
editor.redo()
expect(editor.getText()).toBe('hello')
})
describe('dangerouslyInsertHtml API', () => {
beforeEach(() => {
editor = createEditor()
})
// 现在使用的是 packages/core 的 createEditor ,创建的 editor 没有内置各种 module
// 所以 dangerouslyInsertHtml 在此测试基本功能即可。其他 tag 在各自的 module 中测试
test('dangerouslyInsertHtml should insert text with no blank to editor', () => {
// insertText 必须要设置 selection 才能生效
setEditorSelection(editor)
const htmlString = 'wangEditor!'
editor.dangerouslyInsertHtml(htmlString)
expect(editor.getText().indexOf('wangEditor')).toBeGreaterThan(-1)
})
ignoreTag.forEach(tag => {
test(`insert html string with ${tag} element should to be ignore`, () => {
setEditorSelection(editor)
const htmlString = `<${tag}>${tag}>`
editor.dangerouslyInsertHtml(htmlString)
expect(editor.getHtml().indexOf(tag)).toBe(-1)
})
})
})
it('getParentNode', () => {
const textNode = { text: 'hello' }
const p = { type: 'paragraph', children: [textNode] }
const editor = createEditor({
content: [p],
})
const parentNode = editor.getParentNode(textNode) as any
expect(parentNode).not.toBeNull()
expect(parentNode.type).toBe('paragraph')
})
it('insertNode', () => {
const editor = createEditor()
editor.select(getStartLocation(editor))
const p = { type: 'paragraph', children: [{ text: 'hello' }] }
editor.insertNode(p)
const pList = editor.getElemsByTypePrefix('paragraph')
expect(pList.length).toBe(2)
})
describe('setHtml', () => {
it('setHtml normal', () => {
const editor = createEditor({ html: 'hello' })
editor.select(getStartLocation(editor))
const newHtml = 'world'
editor.setHtml(newHtml)
expect(editor.getHtml()).toBe(newHtml)
})
it('setHtml blur', () => {
const editor = createEditor({
html: 'hello',
autoFocus: false,
})
expect(editor.isFocused()).toBe(false)
const newHtml = 'world'
editor.setHtml(newHtml)
expect(editor.getHtml()).toBe(newHtml)
expect(editor.isFocused()).toBe(false)
})
it('setHtml disabled', () => {
const editor = createEditor({ html: 'hello' })
editor.disable()
expect(editor.isDisabled()).toBe(true)
const newHtml = 'world'
editor.setHtml(newHtml)
expect(editor.getHtml()).toBe(newHtml)
expect(editor.isDisabled()).toBe(true)
})
})
})
================================================
FILE: packages/core/__tests__/editor/plugins/with-dom.test.ts
================================================
/**
* @description editor DOM API test
* @author wangfupeng
*/
import { Editor } from 'slate'
import createCoreEditor from '../../create-core-editor' // packages/core 不依赖 packages/editor ,不能使用后者的 createEditor
import { withDOM } from '../../../src/editor/plugins/with-dom'
function createEditor(...args) {
return withDOM(createCoreEditor(...args))
}
describe('editor DOM API', () => {
function getStartLocation(editor) {
return Editor.start(editor, [])
}
it('editor id', () => {
const editor = createEditor()
expect(editor.id).not.toBeNull()
})
it('isFullScreen fullScreen unFullScreen', done => {
const editor = createEditor()
expect(editor.isFullScreen).toBeFalsy()
editor.fullScreen()
expect(editor.isFullScreen).toBeTruthy()
editor.unFullScreen()
setTimeout(() => {
expect(editor.isFullScreen).toBeFalsy()
done()
}, 1000)
})
// TODO focus blur isFocused 用 jest 测试异常,以及 editor-config.test.ts 中的 `onFocus` `onBlur`
it('disable isDisabled enable', () => {
const editor = createEditor()
editor.select(getStartLocation(editor))
expect(editor.isDisabled()).toBeFalsy()
editor.insertText('123')
expect(editor.getText().length).toBe(3)
editor.disable()
expect(editor.isDisabled()).toBeTruthy()
editor.insertText('123') // disabled ,不会插入
expect(editor.getText().length).toBe(3)
editor.enable()
expect(editor.isDisabled()).toBeFalsy()
editor.insertText('123') // enable ,可以插入
expect(editor.getText().length).toBe(6)
})
it('destroy', done => {
const editor = createEditor()
expect(editor.isDestroyed).toBeFalsy()
setTimeout(() => {
editor.destroy()
expect(editor.isDestroyed).toBeTruthy()
done()
})
})
it('toDOMNode', done => {
const p = { type: 'paragraph', children: [{ text: 'hello' }] }
const editor = createEditor({
content: [p],
})
setTimeout(() => {
const domNode = editor.toDOMNode(p)
expect(domNode.tagName).toBe('DIV')
done()
})
})
})
================================================
FILE: packages/core/__tests__/editor/plugins/with-emitter.test.ts
================================================
/**
* @description editor eventBus API test
* @author wangfupeng
*/
import createCoreEditor from '../../create-core-editor' // packages/core 不依赖 packages/editor ,不能使用后者的 createEditor
import { withEmitter } from '../../../src/editor/plugins/with-emitter'
function createEditor(...args) {
return withEmitter(createCoreEditor(...args))
}
describe('eventBus API', () => {
it('bind and emit', () => {
const editor = createEditor()
const fn1 = jest.fn() // jest mock function
const fn2 = jest.fn()
const fn3 = jest.fn()
editor.on('key1', fn1)
editor.on('key1', fn2)
editor.on('xxxx', fn3)
editor.emit('key1', 10, 20)
expect(fn1).toBeCalledWith(10, 20)
expect(fn2).toBeCalledWith(10, 20)
expect(fn3).not.toBeCalled()
})
it('off single event', () => {
const editor = createEditor()
const fn1 = jest.fn()
const fn2 = jest.fn()
editor.on('key1', fn1)
editor.on('key1', fn2)
editor.off('key1', fn1)
editor.emit('key1', 10, 20)
expect(fn1).not.toBeCalled()
expect(fn2).toBeCalledWith(10, 20)
})
it('once', () => {
const editor = createEditor()
let n = 1
const fn1 = jest.fn(() => n++)
const fn2 = jest.fn(() => n++)
editor.once('key1', fn1)
editor.once('key1', fn2)
// 无论 emit 多少次,只有一次生效
editor.emit('key1')
editor.emit('key1')
editor.emit('key1')
editor.emit('key1')
editor.emit('key1')
expect(n).toBe(3)
})
})
================================================
FILE: packages/core/__tests__/editor/plugins/with-selection.test.ts
================================================
/**
* @description selection API test
* @author wangfupeng
*/
import { Editor } from 'slate'
import createCoreEditor from '../../create-core-editor' // packages/core 不依赖 packages/editor ,不能使用后者的 createEditor
import { withSelection } from '../../../src/editor/plugins/with-selection'
function createEditor(...args) {
return withSelection(createCoreEditor(...args))
}
describe('editor selection API', () => {
function getStartLocation(editor) {
return Editor.start(editor, [])
}
function genParagraph() {
return { type: 'paragraph', children: [{ text: 'hello' }] }
}
// selection select deselect move 是 slate 自带 API 或属性,不测试
// // TODO 运行报错,看源码有使用 focus ,可能和这个相关???
// it('restoreSelection', () => {
// const editor = createEditor()
// editor.select(getStartLocation(editor))
// editor.deselect()
// expect(editor.selection).toBeNull()
// editor.restoreSelection()
// expect(editor.selection).not.toBeNull()
// // console.log(111, JSON.stringify(editor.selection))
// })
it('isSelectedAll', () => {
const p = genParagraph()
const editor = createEditor({ content: [p] })
expect(editor.isSelectedAll()).toBeFalsy()
editor.select(getStartLocation(editor))
expect(editor.isSelectedAll()).toBeFalsy()
editor.select([])
expect(editor.isSelectedAll()).toBeTruthy()
})
})
================================================
FILE: packages/core/__tests__/i18n/index.test.ts
================================================
/**
* @description i18n test
* @author wangfupeng
*/
import i18next, { i18nAddResources, i18nChangeLanguage, t } from '../../src/i18n'
describe('i18n', () => {
// 添加语言项
i18nAddResources('en', {
module1: {
hello: 'hello',
},
})
i18nAddResources('zh-CN', {
module1: {
hello: '你好',
},
})
it('default lang', () => {
expect(i18next.language).toBe('zh-CN')
expect(t('module1.hello')).toBe('你好')
})
it('change lang', () => {
i18nChangeLanguage('en')
expect(i18next.language).toBe('en')
expect(t('module1.hello')).toBe('hello')
})
})
================================================
FILE: packages/core/__tests__/menus/README.md
================================================
# menus test
TODO 各个 modules 中没有这块代码的测试,待编写...
================================================
FILE: packages/core/__tests__/menus/register-menus/index.ts
================================================
/**
* @description 注册菜单,入口
* @author wangfupeng
*/
import './register-button-menu'
import './register-select-menu'
import './register-modal-menu'
================================================
FILE: packages/core/__tests__/menus/register-menus/register-button-menu.ts
================================================
/**
* @description 注册菜单 - button menu
* @author wangfupeng
*/
import { registerMenu, IButtonMenu } from '../../../src/menus/index'
import { IDomEditor } from '../../../src/editor/interface'
class MyButtonMenu implements IButtonMenu {
readonly title = 'My Button Menu'
readonly tag = 'button'
getValue(editor: IDomEditor) {
return ''
}
isActive(editor: IDomEditor) {
return false
}
isDisabled(editor: IDomEditor) {
return false
}
exec(editor: IDomEditor, value: string | boolean) {
console.log('do..')
}
}
registerMenu({
key: 'myButtonMenu',
factory() {
return new MyButtonMenu()
},
})
================================================
FILE: packages/core/__tests__/menus/register-menus/register-modal-menu.ts
================================================
/**
* @description 注册菜单 - modal menu
* @author wangfupeng
*/
import { registerMenu, IModalMenu } from '../../../src/menus/index'
import { IDomEditor } from '../../../src/editor/interface'
class MyModalMenu implements IModalMenu {
readonly title = 'My Modal Menu'
readonly tag = 'button'
readonly showModal = true
readonly modalWidth = 300
getValue(editor: IDomEditor) {
return ''
}
isActive(editor: IDomEditor) {
return false
}
isDisabled(editor: IDomEditor) {
return false
}
exec(editor: IDomEditor, value: string | boolean) {
console.log('do..')
}
getModalContentElem(editor: IDomEditor) {
return document.createElement('div')
}
getModalPositionNode(editor: IDomEditor) {
return null
}
}
registerMenu({
key: 'myModalMenu',
factory() {
return new MyModalMenu()
},
})
================================================
FILE: packages/core/__tests__/menus/register-menus/register-select-menu.ts
================================================
/**
* @description 注册菜单 - select menu
* @author wangfupeng
*/
import { registerMenu, ISelectMenu, IOption } from '../../../src/menus/index'
import { IDomEditor } from '../../../src/editor/interface'
class MySelectMenu implements ISelectMenu {
readonly title = 'My Select Menu'
readonly tag = 'select'
getValue(editor: IDomEditor) {
return ''
}
isActive(editor: IDomEditor) {
return false
}
isDisabled(editor: IDomEditor) {
return false
}
exec(editor: IDomEditor, value: string | boolean) {
console.log('do..')
}
getOptions(): IOption[] {
return [
{ value: 'a', text: 'a' },
{ value: 'b', text: 'b' },
]
}
}
registerMenu({
key: 'mySelectMenu',
factory() {
return new MySelectMenu()
},
})
================================================
FILE: packages/core/__tests__/parse-html/README.md
================================================
# parse-html test
各个 module `parseHtml` 已经测试了该模块的代码。
================================================
FILE: packages/core/__tests__/render/README.md
================================================
# render test
各个 module `renderElem` 已经测试了该模块的代码。
================================================
FILE: packages/core/__tests__/to-html/README.md
================================================
# to-html test
各个 module 中的 `editor.getHtml()` API 会测试到这部分代码。
================================================
FILE: packages/core/__tests__/upload/uploader.test.ts
================================================
/**
* @description uploader test
* @author wangfupeng
*/
import createUploader from '../../src/upload/createUploader'
import { IUploadConfig } from '../../src/upload/interface'
import nock from 'nock'
const server = 'https://fake-endpoint.wangeditor-v5.com'
describe('uploader', () => {
test('if should return Uppy object if invoke createUploader function', () => {
const uppy = createUploader({
server: '/upload',
fieldName: 'file1',
metaWithUrl: true,
meta: {
token: 'xxx',
},
onSuccess: (file, res) => {},
onFailed: (file, res) => {},
onError: (file, err, res) => {},
})
expect(uppy).not.toBeNull()
})
test('it should throw can not get address error if not pass server option', () => {
try {
createUploader({
fieldName: 'file1',
metaWithUrl: false,
onSuccess: (file, res) => {},
onFailed: (file, res) => {},
onError: (file, err, res) => {},
} as IUploadConfig)
} catch (err: unknown) {
expect((err as Error).message).toBe('Cannot get upload server address\n没有配置上传地址')
}
})
test('it should throw can not get fileName error if not pass fileName option', () => {
try {
createUploader({
server: '/upload',
metaWithUrl: false,
onSuccess: (file, res) => {},
onFailed: (file, res) => {},
onError: (file, err, res) => {},
} as IUploadConfig)
} catch (err: unknown) {
expect((err as Error).message).toBe('Cannot get fieldName\n没有配置 fieldName')
}
})
test('it should invoke success callback if file be uploaded successfully', () => {
nock(server)
.defaultReplyHeaders({
'access-control-allow-method': 'POST',
'access-control-allow-origin': '*',
})
.options('/')
.reply(200, {})
.post('/')
.reply(200, {})
const fn = jest.fn()
const uppy = createUploader({
server,
fieldName: 'file1',
metaWithUrl: false,
onSuccess: fn,
onFailed: (file, res) => {},
onError: (file, err, res) => {},
})
// reference https://github.com/transloadit/uppy/blob/main/packages/%40uppy/xhr-upload/src/index.test.js
uppy.addFile({
source: 'jest',
name: 'foo.jpg',
type: 'image/jpeg',
data: new Blob([Buffer.alloc(8192)]),
})
return uppy.upload().then(() => {
expect(fn).toBeCalled()
})
})
test('it should invoke onProgress callback if file be uploaded successfully', () => {
nock(server)
.defaultReplyHeaders({
'access-control-allow-method': 'POST',
'access-control-allow-origin': '*',
})
.options('/')
.reply(200, {})
.post('/')
.reply(200, {})
const fn = jest.fn()
const uppy = createUploader({
server,
fieldName: 'file1',
metaWithUrl: false,
onSuccess: () => {},
onProgress: fn,
onFailed: (file, res) => {},
onError: (file, err, res) => {},
})
// reference https://github.com/transloadit/uppy/blob/main/packages/%40uppy/xhr-upload/src/index.test.js
uppy.addFile({
source: 'jest',
name: 'foo.jpg',
type: 'image/jpeg',
data: new Blob([Buffer.alloc(8192)]),
})
return uppy.upload().then(() => {
expect(fn).toBeCalled()
})
})
test('it should invoke error callback if file be uploaded failed', () => {
nock(server)
.defaultReplyHeaders({
'access-control-allow-method': 'POST',
'access-control-allow-origin': '*',
})
.options('/')
.reply(200, {})
.post('/')
.reply(400, {})
const fn = jest.fn()
const uppy = createUploader({
server,
fieldName: 'file1',
metaWithUrl: false,
onSuccess: () => {},
onFailed: (file, res) => {},
onError: fn,
})
// reference https://github.com/transloadit/uppy/blob/main/packages/%40uppy/xhr-upload/src/index.test.js
uppy.addFile({
source: 'jest',
name: 'foo.jpg',
type: 'image/jpeg',
data: new Blob([Buffer.alloc(8192)]),
})
return uppy.upload().catch(() => {
expect(fn).toBeCalled()
})
})
test('it should invoke console.error method if file be uploaded failed and not pass onError option', () => {
nock(server)
.defaultReplyHeaders({
'access-control-allow-method': 'POST',
'access-control-allow-origin': '*',
})
.options('/')
.reply(200, {})
.post('/')
.reply(400, {})
const fn = jest.fn()
console.error = fn
const uppy = createUploader({
server,
fieldName: 'file1',
metaWithUrl: false,
onSuccess: () => {},
onFailed: (file, res) => {},
} as any)
// reference https://github.com/transloadit/uppy/blob/main/packages/%40uppy/xhr-upload/src/index.test.js
uppy.addFile({
source: 'jest',
name: 'foo.jpg',
type: 'image/jpeg',
data: new Blob([Buffer.alloc(8192)]),
})
return uppy.upload().catch(() => {
expect(fn).toBeCalled()
})
})
test('it should invoke error callback if file size over max size', () => {
const fn = jest.fn()
const uppy = createUploader({
server,
fieldName: 'file1',
metaWithUrl: false,
onSuccess: () => {},
onFailed: (file, res) => {},
onError: fn,
maxFileSize: 5,
})
try {
uppy.addFile({
source: 'jest',
name: 'foo.jpg',
type: 'image/jpeg',
data: new Blob([Buffer.alloc(8192)]),
})
} catch (err) {
expect(fn).toBeCalled()
}
})
})
================================================
FILE: packages/core/__tests__/utils/util.test.ts
================================================
/**
* @description util fns test
* @author wangfupeng
*/
import {
genRandomStr,
addQueryToUrl,
replaceHtmlSpecialSymbols,
deReplaceHtmlSpecialSymbols,
} from '../../src/utils/util'
describe('utils', () => {
it('gen random', () => {
const r1 = genRandomStr()
const r2 = genRandomStr()
expect(r1).not.toBe(r2)
})
it('add query to url', () => {
const params = { a: 10, b: 'hello' }
const url1 = 'https://wangeditor.com/'
expect(addQueryToUrl(url1, params)).toBe('https://wangeditor.com/?a=10&b=hello')
const url2 = 'https://wangeditor.com/?x=1#123'
expect(addQueryToUrl(url2, params)).toBe('https://wangeditor.com/?x=1&a=10&b=hello#123')
})
it('replace html symbol', () => {
const html = 'hello world
'
const res = replaceHtmlSpecialSymbols(html)
expect(res).toBe('<p>hello world</p>')
})
it('replace html symbol', () => {
const html = '<p>hello world</p>'
const res = deReplaceHtmlSpecialSymbols(html)
expect(res).toBe('hello world
')
})
it('decode html quote symbol', () => {
const html = 'hello world
'
const res = deReplaceHtmlSpecialSymbols(html)
expect(res).toBe('hello world
')
})
})
================================================
FILE: packages/core/__tests__/utils/vdom.test.ts
================================================
/**
* @description vdom util fns test
* @author wangfupeng
*/
import { h, VNode } from 'snabbdom'
import {
normalizeVnodeData,
addVnodeProp,
addVnodeDataset,
addVnodeStyle,
} from '../../src/utils/vdom'
describe('vdom util fns', () => {
it('normalize vnode data', () => {
const vnode = h(
'div',
{
key: 'someKey',
id: 'div1',
className: 'someClassName',
'data-custom-name': 'someCustomName',
},
[
h(
'p',
{
id: 'p1',
},
['hello']
),
]
)
normalizeVnodeData(vnode)
// 转换 div 自身
const { data = {}, children = [] } = vnode
expect(data.key).toBe('someKey')
const { props = {}, dataset = {} } = data
expect(props.id).toBe('div1')
expect(props.className).toBe('someClassName')
expect(dataset.customName).toBe('someCustomName')
// 转换 div 子节点 p
const pVNode = (children[0] || {}) as VNode
const { props: pProps = {} } = pVNode.data || {}
expect(pProps.id).toBe('p1')
})
it('add vnode props', () => {
const vnode = h('div', {})
addVnodeProp(vnode, { k1: 'v1' })
const { props = {} } = vnode.data || {}
expect(props.k1).toBe('v1')
})
it('add vnode dataset', () => {
const vnode = h('div', {})
addVnodeDataset(vnode, { k1: 'v1' })
const { dataset = {} } = vnode.data || {}
expect(dataset.k1).toBe('v1')
})
it('add vnode style', () => {
const vnode = h('div', {})
addVnodeStyle(vnode, { k1: 'v1' })
const { style = {} } = vnode.data || {}
expect(style.k1).toBe('v1')
})
})
================================================
FILE: packages/core/package.json
================================================
{
"name": "@wangeditor/core",
"version": "1.1.19",
"description": "wangEditor core",
"author": "wangfupeng1988 ",
"contributors": [],
"homepage": "https://github.com/wangeditor-team/wangEditor#readme",
"license": "MIT",
"types": "dist/core/src/index.d.ts",
"main": "dist/index.js",
"module": "dist/index.esm.js",
"browser": {
"./dist/index.js": "./dist/index.js",
"./dist/index.esm.js": "./dist/index.esm.js"
},
"directories": {
"lib": "dist",
"test": "__tests__"
},
"files": [
"dist"
],
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.com/"
},
"repository": {
"type": "git",
"url": "git+https://github.com/wangeditor-team/wangEditor.git"
},
"scripts": {
"test": "jest",
"test-c": "jest --coverage",
"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-team/wangEditor/issues"
},
"peerDependencies": {
"@uppy/core": "^2.1.1",
"@uppy/xhr-upload": "^2.0.3",
"dom7": "^3.0.0",
"is-hotkey": "^0.2.0",
"lodash.camelcase": "^4.3.0",
"lodash.clonedeep": "^4.5.0",
"lodash.debounce": "^4.0.8",
"lodash.foreach": "^4.5.0",
"lodash.isequal": "^4.5.0",
"lodash.throttle": "^4.1.1",
"lodash.toarray": "^4.4.0",
"nanoid": "^3.2.0",
"slate": "^0.72.0",
"snabbdom": "^3.1.0"
},
"dependencies": {
"@types/event-emitter": "^0.3.3",
"event-emitter": "^0.3.5",
"html-void-elements": "^2.0.0",
"i18next": "^20.4.0",
"scroll-into-view-if-needed": "^2.2.28",
"slate-history": "^0.66.0"
},
"devDependencies": {
"@types/is-hotkey": "^0.1.2"
}
}
================================================
FILE: packages/core/rollup.config.js
================================================
import { createRollupConfig, IS_PRD } from '../../build/create-rollup-config'
import pkg from './package.json'
const name = 'WangEditorCore'
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
================================================
FILE: packages/core/src/assets/bar-item.less
================================================
@import "../../../vars.less"; // var and mixin
.w-e-bar-divider {
display: inline-flex;
width: 1px;
height: @toolbar-height;
background-color: @toolbar-border-color; // 分割线 bgColor
margin: 0 5px;
}
.w-e-bar-item {
position: relative;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 4px;
height: @toolbar-height;
button {
border: none;
background: transparent;
height: calc(@toolbar-height - 8px);
padding: 0 8px;
cursor: pointer;
display: inline-flex;
justify-content: center;
align-items: center;
color: @toolbar-color;
white-space: nowrap; /* 不换行 */
overflow: hidden;
&:hover {
background-color: @toolbar-active-bg-color;
color: @toolbar-active-color;
}
.title {
margin-left: 5px;
}
}
.active {
background-color: @toolbar-active-bg-color;
color: @toolbar-active-color;
}
.disabled {
color: @toolbar-disabled-color;
cursor: not-allowed;
svg {
fill: @toolbar-disabled-color;
}
&:hover {
background-color: @toolbar-bg-color;
color: @toolbar-disabled-color;
svg {
fill: @toolbar-disabled-color;
}
}
}
}
// ------------------------------------- 分割线 -------------------------------------
// tooltip - bottom
.w-e-menu-tooltip-v5 {
&:before {
content: attr(data-tooltip);
position: absolute;
background-color: @toolbar-active-color; // tooltip 颜色反转,黑底白字
color: @toolbar-bg-color; // tooltip 颜色反转,黑底白字
text-align: center;
padding: 5px 10px;
border-radius: 5px;
z-index: 1;
opacity: 0;
transition: opacity 0.6s;
font-size: 0.75em;
visibility: hidden;
top: @toolbar-height;
white-space: pre;
}
// arrow
&:after {
content: "";
position: absolute;
border-width: 5px;
border-style: solid;
opacity: 0;
transition: opacity 0.6s;
border-color: transparent transparent @toolbar-active-color transparent;
visibility: hidden;
top: 30px;
}
&:hover:before,
&:hover:after {
opacity: 1;
visibility: visible;
}
}
// tooltip - right
.w-e-menu-tooltip-v5.tooltip-right {
&:before {
left: 100%;
top: 10px;
}
// arrow
&:after {
left: 100%;
margin-left: -10px;
top: 16px;
border-color: transparent @toolbar-active-color transparent transparent;
}
}
// ------------------------------------- 分割线 -------------------------------------
// barItem group
.w-e-bar-item-group {
.w-e-bar-item-menus-container {
display: none; /* 默认隐藏 */
z-index: 1;
background-color: @toolbar-bg-color;
position: absolute;
top: 0;
left: 0;
margin-top: @toolbar-height;
.shadowBordered(10px);
}
&:hover {
/* hover 时显示下级菜单 */
.w-e-bar-item-menus-container {
display: block;
}
}
}
================================================
FILE: packages/core/src/assets/bar.less
================================================
@import "../../../vars.less"; // var and mixin
.w-e-bar {
background-color: @toolbar-bg-color;
padding: 0 5px;
font-size: @size;
color: @toolbar-color;
svg {
width: @size;
height: @size;
fill: @toolbar-color;
}
}
.w-e-bar-show {
display: flex;
}
.w-e-bar-hidden {
display: none;
}
.w-e-hover-bar {
position: absolute;
.shadowBordered();
}
.w-e-toolbar {
flex-wrap: wrap;
position: relative;
}
================================================
FILE: packages/core/src/assets/common.less
================================================
.w-e-text-container *,
.w-e-toolbar * {
padding: 0;
margin: 0;
box-sizing: border-box;
outline: none;
}
.w-e-text-container {
p, li, td, th, blockquote {
line-height: 1.5;
}
}
.w-e-toolbar * {
line-height: 1.5;
}
================================================
FILE: packages/core/src/assets/drop-panel.less
================================================
@import "../../../vars.less"; // var and mixin
.w-e-drop-panel {
z-index: 1;
background-color: @toolbar-bg-color;
position: absolute;
top: 0;
.shadowBordered(10px);
margin-top: @toolbar-height;
min-width: 200px;
padding: 10px;
}
// 当 bar 处于页面下方,则 dropPanel 要显示在 bar 上方
.w-e-bar-bottom .w-e-drop-panel {
top: inherit;
bottom: 0;
margin-top: 0;
margin-bottom: @toolbar-height;
}
================================================
FILE: packages/core/src/assets/full-screen.less
================================================
.w-e-full-screen-container {
position: fixed;
margin: 0 !important;
padding: 0 !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
height: 100% !important;
width: 100% !important;
display: flex !important;
flex-direction: column !important;
// [data-w-e-toolbar="true"] {
// }
[data-w-e-textarea="true"] {
flex: 1 !important;
}
}
================================================
FILE: packages/core/src/assets/index.less
================================================
@import "common.less";
@import "textarea.less";
@import "bar.less";
@import "bar-item.less";
@import "select-list.less";
@import "drop-panel.less";
@import "modal.less";
@import "progress.less";
@import "full-screen.less";
================================================
FILE: packages/core/src/assets/modal.less
================================================
@import "../../../vars.less"; // var and mixin
.w-e-modal {
z-index: 1;
background-color: @toolbar-bg-color;
position: absolute;
padding: 20px 15px 0 15px;
min-width: 100px;
min-height: 40px;
color: @toolbar-color;
text-align: left;
font-size: @size;
.shadowBordered(10px);
.btn-close {
position: absolute;
right: 8px;
top: 7px;
cursor: pointer;
padding: 5px;
line-height: 1;
svg {
width: 10px;
height: 10px;
fill: @toolbar-color;
}
}
.babel-container {
display: block;
margin-bottom: 15px;
span {
display: block;
margin-bottom: 10px;
}
}
.button-container {
margin-bottom: 15px;
}
button {
font-weight: 400;
white-space: nowrap;
cursor: pointer;
transition: all .3s cubic-bezier(.645,.045,.355,1);
user-select: none;
touch-action: manipulation;
height: 32px;
padding: 4.5px 15px;
color: @toolbar-color;
background-color: @modal-button-bg-color;
text-align: center;
border: 1px solid @modal-button-border-color;
border-radius: 4px;
}
textarea,
input[type="text"],
input[type="number"] {
font-variant: tabular-nums;
font-feature-settings: "tnum";
padding: 4.5px 11px;
color: @toolbar-color;
background-color: @toolbar-bg-color;
border: 1px solid @modal-button-border-color;
border-radius: 4px;
transition: all .3s;
width: 100%;
}
textarea {
min-height: 60px;
}
}
// modal 有可能直接 append 到 下面
body .w-e-modal {
box-sizing: border-box;
* {
box-sizing: border-box;
}
}
================================================
FILE: packages/core/src/assets/progress.less
================================================
@import "../../../vars.less";
.w-e-progress-bar {
position: absolute;
width: 0;
height: 1px;
background-color: @textarea-handler-bg-color;
transition: width 0.3s;
}
================================================
FILE: packages/core/src/assets/select-list.less
================================================
@import "../../../vars.less"; // var and mixin
.w-e-select-list {
z-index: 1;
position: absolute;
left: 0;
top: 0;
background-color: @toolbar-bg-color;
margin-top: @toolbar-height;
min-width: 100px;
.shadowBordered(10px);
max-height: 350px;
overflow-y: auto;
ul {
list-style: none;
line-height: 1;
.selected {
background-color: @toolbar-active-bg-color;
}
li {
cursor: pointer;
padding: 7px 0 7px 25px;
position: relative;
text-align: left;
white-space: nowrap; /* 不换行 */
&:hover {
background-color: @toolbar-active-bg-color;
}
svg {
position: absolute;
left: 0;
margin-left: 5px;
top: 50%;
margin-top: -7px;
}
}
}
}
// 当 bar 处于页面下方,则 selectList 要显示在 bar 上方
.w-e-bar-bottom .w-e-select-list {
top: inherit;
bottom: 0;
margin-top: 0;
margin-bottom: @toolbar-height;
}
================================================
FILE: packages/core/src/assets/textarea.less
================================================
@import "../../../vars.less"; // var and mixin
.w-e-text-container {
color: @textarea-color;
background-color: @textarea-bg-color;
position: relative;
height: 100%;
}
.w-e-text-container .w-e-scroll {
height: 100%;
// overflow-y: auto; // 在 js 中设置,根据 config 判断是否增加 scroll
-webkit-overflow-scrolling: touch;
}
.w-e-text-container [data-slate-editor] {
outline: 0;
white-space: pre-wrap; /* 【重要】可以显示空格,在连续多空格的情况下 */
word-wrap: break-word;
padding: 0 10px;
border-top: 1px solid transparent; // 防止 margin-top 溢出
min-height: 100%;
p {
margin: 15px 0;
}
h1,h2,h3,h4,h5 {
margin: 20px 0 20px 0;
}
img {
max-width: 100%;
min-width: 20px;
min-height: 20px;
cursor: default;
display: inline !important;
}
span {
text-indent: 0; // issues#4536
}
// 选中的节点
[data-selected="true"] {
box-shadow: 0 0 0 2px @textarea-selected-border-color;
}
}
.w-e-text-placeholder {
color: @textarea-slight-color;
position: absolute;
font-style: italic;
width: 90%;
left: 10px;
top: 17px;
pointer-events: none; // 忽略鼠标行为,重要
user-select: none;
}
.w-e-max-length-info {
position: absolute;
color: @textarea-slight-color;
bottom: 0.5em;
right: 1em;
pointer-events: none; // 忽略鼠标行为,重要
user-select: none;
}
================================================
FILE: packages/core/src/config/index.ts
================================================
/**
* @description editor config
* @author wangfupeng
*/
import forEach from 'lodash.foreach'
import cloneDeep from 'lodash.clonedeep'
import { IEditorConfig, IMenuConfig, IToolbarConfig } from './interface'
import { GLOBAL_MENU_CONF } from './register'
/**
* 生成编辑器默认配置
*/
export function genEditorConfig(userConfig: Partial = {}): IEditorConfig {
const defaultMenuConf = cloneDeep(GLOBAL_MENU_CONF)
const newMenuConf: IMenuConfig = {}
// 单独处理 menuConf
const { MENU_CONF: userMenuConf = {} } = userConfig
forEach(defaultMenuConf, (menuConf, menuKey) => {
// 生成新的 menu config
newMenuConf[menuKey] = {
...menuConf,
...(userMenuConf[menuKey] || {}),
}
})
delete userConfig.MENU_CONF // 处理完,则删掉 menuConf ,以防下面 merge 时造成干扰
return {
// 默认配置
scroll: true,
readOnly: false,
autoFocus: true,
decorate: () => [],
maxLength: 0, // 默认不限制
MENU_CONF: newMenuConf,
hoverbarKeys: {
// 'link': { menuKeys: ['editLink', 'unLink', 'viewLink'] },
},
customAlert(info: string, type: string) {
window.alert(`${type}:\n${info}`)
},
// 合并用户配置
...userConfig,
}
}
/**
* 生成 toolbar 默认配置
*/
export function genToolbarConfig(userConfig?: Partial): IToolbarConfig {
return {
// 默认配置
toolbarKeys: [],
excludeKeys: [],
insertKeys: { index: 0, keys: [] },
modalAppendToBody: false,
// 合并用户配置
...(userConfig || {}),
}
}
================================================
FILE: packages/core/src/config/interface.ts
================================================
/**
* @description config interface
* @author wangfupeng
*/
import { Range, NodeEntry, Node } from 'slate'
import { IDomEditor } from '../editor/interface'
import { IMenuGroup } from '../menus/interface'
interface IHoverbarConf {
// key 即 element type
[key: string]: {
match?: (editor: IDomEditor, n: Node) => boolean // 自定义匹配函数,优先级高于“key 即 element type”
menuKeys: string[]
}
}
export type AlertType = 'success' | 'info' | 'warning' | 'error'
export interface ISingleMenuConfig {
[key: string]: any
}
export interface IMenuConfig {
[key: string]: ISingleMenuConfig
}
/**
* editor config
*/
export interface IEditorConfig {
//【注意】如增加 onXxx 回调函数时,要同步到 vue2/vue3 组件
customAlert: (info: string, type: AlertType) => void
onCreated?: (editor: IDomEditor) => void
onChange?: (editor: IDomEditor) => void
onDestroyed?: (editor: IDomEditor) => void
onMaxLength?: (editor: IDomEditor) => void
onFocus?: (editor: IDomEditor) => void
onBlur?: (editor: IDomEditor) => void
/**
* 自定义粘贴。返回 true 则继续粘贴,返回 false 则自行实现粘贴,阻止默认粘贴
*/
customPaste?: (editor: IDomEditor, e: ClipboardEvent) => boolean
// edit state
scroll: boolean
placeholder?: string
readOnly: boolean
autoFocus: boolean
decorate?: (nodeEntry: NodeEntry) => Range[]
maxLength?: number
// 各个 menu 的配置汇总,可以通过 key 获取单个 menu 的配置
MENU_CONF?: IMenuConfig
// 悬浮菜单栏 menu
hoverbarKeys?: IHoverbarConf
// 自由扩展其他配置
EXTEND_CONF?: any
}
/**
* toolbar config
*/
export interface IToolbarConfig {
toolbarKeys: Array
insertKeys: { index: number; keys: string | Array }
excludeKeys: Array // 排除哪些菜单
modalAppendToBody: boolean // modal append 到 body ,而非 $textAreaContainer 内
}
================================================
FILE: packages/core/src/config/register.ts
================================================
/**
* @description config register
* @author wangfupeng
*/
import { IMenuConfig, ISingleMenuConfig } from '../config/interface'
// 全局的菜单配置
export const GLOBAL_MENU_CONF: IMenuConfig = {}
/**
* 注册全局菜单配置
* @param key menu key
* @param config config
*/
export function registerGlobalMenuConf(key: string, config?: ISingleMenuConfig) {
if (config == null) return
GLOBAL_MENU_CONF[key] = config
}
================================================
FILE: packages/core/src/constants/index.ts
================================================
export const IGNORE_TAGS = new Set([
'doctype',
'!doctype',
'meta',
'script',
'style',
'link',
'frame',
'iframe',
'title',
'svg', // TODO 暂时忽略
])
================================================
FILE: packages/core/src/constants/svg.ts
================================================
/**
* @description svg tag
* @author wangfupeng
*/
/**
* 【注意】svg 字符串的长度 ,否则会导致代码体积过大
* 尽量选择 https://www.iconfont.cn/collections/detail?spm=a313x.7781069.0.da5a778a4&cid=20293
* 找不到再从 iconfont.com 搜索
*/
// 对号
export const SVG_CHECK_MARK =
''
// 向下的箭头
export const SVG_DOWN_ARROW =
''
// 关闭
export const SVG_CLOSE =
''
================================================
FILE: packages/core/src/create/bind-node-relation.ts
================================================
/**
* @description 绑定 node 的关系
* @author wangfupeng
*/
import { Element, Editor, Node, Ancestor } from 'slate'
import { IDomEditor } from '../editor/interface'
import { NODE_TO_INDEX, NODE_TO_PARENT } from '../utils/weak-maps'
/**
* createEditor 未传递 selector 时,绑定 node 的关系( NODE_TO_PARENT, NODE_TO_INDEX 等 )
* @param node node
* @param index index
* @param parent parent node
* @param editor editor
*/
function bindNodeRelation(node: Node, index: number, parent: Ancestor, editor: IDomEditor) {
// 设置相关 weakMap 信息
NODE_TO_INDEX.set(node, index)
NODE_TO_PARENT.set(node, parent)
if (Element.isElement(node)) {
const { children = [] } = node
children.forEach((child: Node, i: number) => bindNodeRelation(child, i, node, editor)) // 递归子节点
const isVoid = Editor.isVoid(editor, node)
if (isVoid) {
const [[text]] = Node.texts(node)
// 记录 text 相关 weakMap
NODE_TO_INDEX.set(text, 0)
NODE_TO_PARENT.set(text, node)
}
}
}
export default bindNodeRelation
================================================
FILE: packages/core/src/create/create-editor.ts
================================================
/**
* @description create editor
* @author wangfupeng
*/
import { createEditor, Descendant } from 'slate'
import { withHistory } from 'slate-history'
import { withDOM } from '../editor/plugins/with-dom'
import { withConfig } from '../editor/plugins/with-config'
import { withContent } from '../editor/plugins/with-content'
import { withEventData } from '../editor/plugins/with-event-data'
import { withEmitter } from '../editor/plugins/with-emitter'
import { withSelection } from '../editor/plugins/with-selection'
import { withMaxLength } from '../editor/plugins/with-max-length'
import TextArea from '../text-area/TextArea'
import HoverBar from '../menus/bar/HoverBar'
import { genEditorConfig } from '../config/index'
import { IDomEditor } from '../editor/interface'
import { DomEditor } from '../editor/dom-editor'
import { IEditorConfig } from '../config/interface'
import { promiseResolveThen } from '../utils/util'
import { isRepeatedCreateTextarea, genDefaultContent, htmlToContent } from './helper'
import type { DOMElement } from '../utils/dom'
import {
EDITOR_TO_TEXTAREA,
TEXTAREA_TO_EDITOR,
EDITOR_TO_CONFIG,
HOVER_BAR_TO_EDITOR,
EDITOR_TO_HOVER_BAR,
} from '../utils/weak-maps'
import bindNodeRelation from './bind-node-relation'
import $ from '../utils/dom'
type PluginFnType = (editor: T) => T
interface ICreateOption {
selector: string | DOMElement
config: Partial
content?: Descendant[]
html?: string
plugins: PluginFnType[]
}
/**
* 创建编辑器
*/
export default function (option: Partial) {
const { selector = '', config = {}, content, html, plugins = [] } = option
// 创建实例 - 使用插件
let editor = withHistory(
withMaxLength(
withEmitter(withSelection(withContent(withConfig(withDOM(withEventData(createEditor()))))))
)
)
if (selector) {
// 检查是否对同一个 DOM 重复创建
if (isRepeatedCreateTextarea(editor, selector)) {
throw new Error(`Repeated create editor by selector '${selector}'`)
}
}
// 处理配置
const editorConfig = genEditorConfig(config)
EDITOR_TO_CONFIG.set(editor, editorConfig)
const { hoverbarKeys = {} } = editorConfig
// 注册第三方插件
plugins.forEach(plugin => {
editor = plugin(editor)
})
// 初始化内容(要在 config 和 plugins 后面)
if (html != null) {
// 传入 html ,转换为 JSON content
editor.children = htmlToContent(editor, html)
}
if (content && content.length) {
editor.children = content // 传入 JSON content
}
if (editor.children.length === 0) {
editor.children = genDefaultContent() // 默认内容
}
DomEditor.normalizeContent(editor) // 格式化,用户输入的 content 可能不规范(如两个相连的 text 没有合并)
if (selector) {
// 传入了 selector ,则创建 textarea DOM
const textarea = new TextArea(selector)
EDITOR_TO_TEXTAREA.set(editor, textarea)
TEXTAREA_TO_EDITOR.set(textarea, editor)
textarea.changeViewState() // 初始化时触发一次,以便能初始化 textarea DOM 和 selection
// 判断 textarea 最小高度,并给出提示
promiseResolveThen(() => {
const $scroll = textarea.$scroll
if ($scroll == null) return
if ($scroll.height() < 300) {
let info = '编辑区域高度 < 300px 这可能会导致 modal hoverbar 定位异常'
info += '\nTextarea height < 300px . This may be cause modal and hoverbar position error'
console.warn(info, $scroll)
}
})
// 创建 hoverbar DOM
let hoverbar: HoverBar | null
if (Object.keys(hoverbarKeys).length > 0) {
hoverbar = new HoverBar()
HOVER_BAR_TO_EDITOR.set(hoverbar, editor)
EDITOR_TO_HOVER_BAR.set(editor, hoverbar)
}
// 隐藏 panel and modal
editor.on('change', () => {
editor.hidePanelOrModal()
})
editor.on('scroll', () => {
editor.hidePanelOrModal()
})
} else {
// 未传入 selector ,则遍历 content ,绑定一些 WeakMap 关系 ( NODE_TO_PARENT, NODE_TO_INDEX 等 )
editor.children.forEach((node, i) => bindNodeRelation(node, i, editor, editor))
}
// 触发生命周期
const { onCreated, onDestroyed } = editorConfig
if (onCreated) {
editor.on('created', () => onCreated(editor))
}
if (onDestroyed) {
editor.on('destroyed', () => onDestroyed(editor))
}
// 创建完毕,异步触发 created
promiseResolveThen(() => editor.emit('created'))
return editor
}
================================================
FILE: packages/core/src/create/create-toolbar.ts
================================================
/**
* @description create toolbar
* @author wangfupeng
*/
import { IDomEditor } from '../editor/interface'
import Toolbar from '../menus/bar/Toolbar'
import { IToolbarConfig } from '../config/interface'
import { genToolbarConfig } from '../config/index'
import { isRepeatedCreateToolbar } from './helper'
import { DOMElement } from '../utils/dom'
import { TOOLBAR_TO_EDITOR, EDITOR_TO_TOOLBAR } from '../utils/weak-maps'
interface ICreateOption {
selector: string | DOMElement
config?: Partial
}
export default function (editor: IDomEditor | null, option: ICreateOption): Toolbar {
if (editor == null) {
throw new Error(`Cannot create toolbar, because editor is null`)
}
const { selector, config = {} } = option
// 避免重复创建
if (isRepeatedCreateToolbar(editor, selector)) {
// 对同一个 DOM 重复创建
throw new Error(`Repeated create toolbar by selector '${selector}'`)
}
// 处理配置
const toolbarConfig = genToolbarConfig(config)
// 创建 toolbar ,并记录和 editor 关系
const toolbar = new Toolbar(selector, toolbarConfig)
TOOLBAR_TO_EDITOR.set(toolbar, editor)
EDITOR_TO_TOOLBAR.set(editor, toolbar)
return toolbar
}
================================================
FILE: packages/core/src/create/helper.ts
================================================
/**
* @description create helper
* @author wangfupeng
*/
import { Descendant } from 'slate'
import { IDomEditor } from '../editor/interface'
import parseElemHtml from '../parse-html/parse-elem-html'
import $, { DOMElement } from '../utils/dom'
function isRepeatedCreate(
editor: IDomEditor,
attrKey: string,
selector: string | DOMElement
): boolean {
// @ts-ignore
const $elem = $(selector)
if ($elem.attr(attrKey)) {
return true // 有属性,说明已经创建过
}
// 至此,说明未创建过,则记录
$elem.attr(attrKey, 'true')
// 销毁时删除属性
editor.on('destroyed', () => {
$elem.removeAttr(attrKey)
})
return false
}
/**
* 检查是否重复创建 textarea
*/
export function isRepeatedCreateTextarea(
editor: IDomEditor,
selector: string | DOMElement
): boolean {
return isRepeatedCreate(editor, 'data-w-e-textarea', selector)
}
/**
* 检查是否重复创建 toolbar
*/
export function isRepeatedCreateToolbar(
editor: IDomEditor,
selector: string | DOMElement
): boolean {
return isRepeatedCreate(editor, 'data-w-e-toolbar', selector)
}
/**
* 生成默认 content
*/
export function genDefaultContent() {
return [
{
type: 'paragraph',
children: [{ text: '' }],
},
]
}
/**
* html 字符串 -> content
* @param editor editor
* @param html html 字符串
*/
export function htmlToContent(editor: IDomEditor, html: string = ''): Descendant[] {
const res: Descendant[] = []
// 空白内容
if (html === '') html = '
'
// 非 HTML 格式,文本格式,用 包裹
if (html.indexOf('<') !== 0) {
html = html
.split(/\n/)
.map(line => `
${line}
`)
.join('')
}
const $content = $(`${html}`)
const list = Array.from($content.children())
list.forEach(child => {
const $child = $(child)
const parsedRes = parseElemHtml($child, editor)
if (Array.isArray(parsedRes)) {
parsedRes.forEach(el => res.push(el))
} else {
res.push(parsedRes)
}
})
return res
}
================================================
FILE: packages/core/src/create/index.ts
================================================
/**
* @description create entry
* @author wangfupeng
*/
import coreCreateEditor from './create-editor'
import coreCreateToolbar from './create-toolbar'
export { coreCreateEditor, coreCreateToolbar }
================================================
FILE: packages/core/src/editor/dom-editor.ts
================================================
/**
* @description 扩展 slate Editor(参考 slate-react react-editor.ts )
* @author wangfupeng
*/
import toArray from 'lodash.toarray'
import { Editor, Node, Element, Path, Point, Range, Ancestor, Text } from 'slate'
import type { IDomEditor } from './interface'
import { Key } from '../utils/key'
import TextArea from '../text-area/TextArea'
import Toolbar from '../menus/bar/Toolbar'
import HoverBar from '../menus/bar/HoverBar'
import {
EDITOR_TO_ELEMENT,
ELEMENT_TO_NODE,
KEY_TO_ELEMENT,
NODE_TO_INDEX,
NODE_TO_KEY,
NODE_TO_PARENT,
EDITOR_TO_TEXTAREA,
EDITOR_TO_TOOLBAR,
EDITOR_TO_HOVER_BAR,
EDITOR_TO_WINDOW,
} from '../utils/weak-maps'
import $, {
DOMElement,
DOMNode,
DOMPoint,
DOMRange,
DOMSelection,
DOMStaticRange,
isDOMElement,
normalizeDOMPoint,
isDOMSelection,
hasShadowRoot,
walkTextNodes,
} from '../utils/dom'
import { IS_CHROME, IS_FIREFOX } from '../utils/ua'
/**
* 自定义全局 command
*/
export const DomEditor = {
/**
* Return the host window of the current editor.
*/
getWindow(editor: IDomEditor): Window {
const window = EDITOR_TO_WINDOW.get(editor)
if (!window) {
throw new Error('Unable to find a host window element for this editor')
}
return window
},
/**
* Find a key for a Slate node.
* key 即一个累加不重复的 id ,每一个 slate node 都对对应一个 key ,意思相当于 node.id
*/
findKey(editor: IDomEditor | null, node: Node): Key {
let key = NODE_TO_KEY.get(node)
// 如果没绑定,就立马新建一个 key 来绑定
if (!key) {
key = new Key()
NODE_TO_KEY.set(node, key)
}
return key
},
setNewKey(node: Node) {
const key = new Key()
NODE_TO_KEY.set(node, key)
},
/**
* Find the path of Slate node.
* path 是一个数组,代表 slate node 的位置 https://docs.slatejs.org/concepts/03-locations#path
*/
findPath(editor: IDomEditor | null, node: Node): Path {
const path: Path = []
let child = node
// eslint-disable-next-line
while (true) {
const parent = NODE_TO_PARENT.get(child)
if (parent == null) {
if (Editor.isEditor(child)) {
// 已到达最顶层,返回 patch
return path
} else {
break
}
}
// 获取该节点在父节点中的 index
const i = NODE_TO_INDEX.get(child)
if (i == null) {
break
}
// 拼接 patch
path.unshift(i)
// 继续向上递归
child = parent
}
throw new Error(`Unable to find the path for Slate node: ${JSON.stringify(node)}`)
},
/**
* Find the DOM node that implements DocumentOrShadowRoot for the editor.
*/
findDocumentOrShadowRoot(editor: IDomEditor): Document | ShadowRoot {
if (editor.isDestroyed) {
return window.document
}
const el = DomEditor.toDOMNode(editor, editor)
const root = el.getRootNode()
if ((root instanceof Document || root instanceof ShadowRoot) && root.getSelection != null) {
return root
}
return el.ownerDocument
},
/**
* 获取父节点
* @param editor editor
* @param node cur node
*/
getParentNode(editor: IDomEditor | null, node: Node): Ancestor | null {
return NODE_TO_PARENT.get(node) || null
},
/**
* 获取当前节点的所有父节点
* @param editor editor
* @param node cur node
*/
getParentsNodes(editor: IDomEditor, node: Node): Ancestor[] {
const nodes: Ancestor[] = []
let curNode = node
while (curNode !== editor && curNode != null) {
const parentNode = DomEditor.getParentNode(editor, curNode)
if (parentNode == null) {
break
} else {
nodes.push(parentNode)
curNode = parentNode
}
}
return nodes
},
/**
* 获取当前节点对应的顶级节点
* @param editor editor
* @param curNode cur node
*/
getTopNode(editor: IDomEditor, curNode: Node): Node {
const path = DomEditor.findPath(editor, curNode)
const topPath = [path[0]]
return Node.get(editor, topPath)
},
/**
* Find the native DOM element from a Slate node or editor.
*/
toDOMNode(editor: IDomEditor, node: Node): HTMLElement {
let domNode
const isEditor = Editor.isEditor(node)
if (isEditor) {
domNode = EDITOR_TO_ELEMENT.get(editor)
} else {
const key = DomEditor.findKey(editor, node)
domNode = KEY_TO_ELEMENT.get(key)
}
if (!domNode) {
throw new Error(`Cannot resolve a DOM node from Slate node: ${JSON.stringify(node)}`)
}
return domNode
},
/**
* Check if a DOM node is within the editor.
*/
hasDOMNode(editor: IDomEditor, target: DOMNode, options: { editable?: boolean } = {}): boolean {
const { editable = false } = options
const editorEl = DomEditor.toDOMNode(editor, editor)
let targetEl
// COMPAT: In Firefox, reading `target.nodeType` will throw an error if
// target is originating from an internal "restricted" element (e.g. a
// stepper arrow on a number input). (2018/05/04)
// https://github.com/ianstormtaylor/slate/issues/1819
try {
targetEl = (isDOMElement(target) ? target : target.parentElement) as HTMLElement
} catch (err) {
if (!err.message.includes('Permission denied to access property "nodeType"')) {
throw err
}
}
if (!targetEl) {
return false
}
return (
// 祖先节点中包括 data-slate-editor 属性,即 textarea
targetEl.closest(`[data-slate-editor]`) === editorEl &&
// 通过参数 editable 控制开启是否验证是可编辑元素或零宽字符
(!editable || targetEl.isContentEditable || !!targetEl.getAttribute('data-slate-zero-width'))
)
},
/**
* Find a native DOM range from a Slate `range`.
*
* Notice: the returned range will always be ordinal regardless of the direction of Slate `range` due to DOM API limit.
*
* there is no way to create a reverse DOM Range using Range.setStart/setEnd
* according to https://dom.spec.whatwg.org/#concept-range-bp-set.
*/
toDOMRange(editor: IDomEditor, range: Range): DOMRange {
const { anchor, focus } = range
const isBackward = Range.isBackward(range)
const domAnchor = DomEditor.toDOMPoint(editor, anchor)
const domFocus = Range.isCollapsed(range) ? domAnchor : DomEditor.toDOMPoint(editor, focus)
const window = DomEditor.getWindow(editor)
const domRange = window.document.createRange()
const [startNode, startOffset] = isBackward ? domFocus : domAnchor
const [endNode, endOffset] = isBackward ? domAnchor : domFocus
// A slate Point at zero-width Leaf always has an offset of 0 but a native DOM selection at
// zero-width node has an offset of 1 so we have to check if we are in a zero-width node and
// adjust the offset accordingly.
const startEl = (isDOMElement(startNode) ? startNode : startNode.parentElement) as HTMLElement
const isStartAtZeroWidth = !!startEl.getAttribute('data-slate-zero-width')
const endEl = (isDOMElement(endNode) ? endNode : endNode.parentElement) as HTMLElement
const isEndAtZeroWidth = !!endEl.getAttribute('data-slate-zero-width')
domRange.setStart(startNode, isStartAtZeroWidth ? 1 : startOffset)
domRange.setEnd(endNode, isEndAtZeroWidth ? 1 : endOffset)
return domRange
},
/**
* Find a native DOM selection point from a Slate point.
*/
toDOMPoint(editor: IDomEditor, point: Point): DOMPoint {
const [node] = Editor.node(editor, point.path)
const el = DomEditor.toDOMNode(editor, node)
let domPoint: DOMPoint | undefined
// If we're inside a void node, force the offset to 0, otherwise the zero
// width spacing character will result in an incorrect offset of 1
if (Editor.void(editor, { at: point })) {
// void 节点,offset 必须为 0
point = { path: point.path, offset: 0 }
}
// For each leaf, we need to isolate its content, which means filtering
// to its direct text and zero-width spans. (We have to filter out any
// other siblings that may have been rendered alongside them.)
const selector = `[data-slate-string], [data-slate-zero-width]`
const texts = Array.from(el.querySelectorAll(selector))
let start = 0
for (const text of texts) {
const domNode = text.childNodes[0] as HTMLElement
if (domNode == null || domNode.textContent == null) {
continue
}
const { length } = domNode.textContent
const attr = text.getAttribute('data-slate-length')
const trueLength = attr == null ? length : parseInt(attr, 10)
const end = start + trueLength
if (point.offset <= end) {
const offset = Math.min(length, Math.max(0, point.offset - start))
domPoint = [domNode, offset]
break
}
start = end
}
if (!domPoint) {
throw new Error(`Cannot resolve a DOM point from Slate point: ${JSON.stringify(point)}`)
}
return domPoint
},
/**
* Find a Slate node from a native DOM `element`.
*/
toSlateNode(editor: IDomEditor | null, domNode: DOMNode): Node {
let domEl = isDOMElement(domNode) ? domNode : domNode.parentElement
if (domEl && !domEl.hasAttribute('data-slate-node')) {
domEl = domEl.closest(`[data-slate-node]`)
}
const node = domEl ? ELEMENT_TO_NODE.get(domEl as HTMLElement) : null
if (!node) {
throw new Error(`Cannot resolve a Slate node from DOM node: ${domEl}`)
}
return node
},
/**
* Get the target range from a DOM `event`.
*/
findEventRange(editor: IDomEditor, event: any): Range {
if ('nativeEvent' in event) {
// 兼容 react 的合成事件,DOM 事件中没什么用
event = event.nativeEvent
}
const { clientX: x, clientY: y, target } = event
if (x == null || y == null) {
throw new Error(`Cannot resolve a Slate range from a DOM event: ${event}`)
}
const node = DomEditor.toSlateNode(editor, event.target)
const path = DomEditor.findPath(editor, node)
// If the drop target is inside a void node, move it into either the
// next or previous node, depending on which side the `x` and `y`
// coordinates are closest to.
if (Editor.isVoid(editor, node)) {
const rect = target.getBoundingClientRect()
const isPrev = editor.isInline(node)
? x - rect.left < rect.left + rect.width - x
: y - rect.top < rect.top + rect.height - y
const edge = Editor.point(editor, path, {
edge: isPrev ? 'start' : 'end',
})
const point = isPrev ? Editor.before(editor, edge) : Editor.after(editor, edge)
if (point) {
const range = Editor.range(editor, point)
return range
}
}
// Else resolve a range from the caret position where the drop occured.
let domRange
const { document } = this.getWindow(editor)
// COMPAT: In Firefox, `caretRangeFromPoint` doesn't exist. (2016/07/25)
if (document.caretRangeFromPoint) {
domRange = document.caretRangeFromPoint(x, y)
} else {
const position = document.caretPositionFromPoint(x, y)
if (position) {
domRange = document.createRange()
domRange.setStart(position.offsetNode, position.offset)
domRange.setEnd(position.offsetNode, position.offset)
}
}
if (!domRange) {
throw new Error(`Cannot resolve a Slate range from a DOM event: ${event}`)
}
// Resolve a Slate range from the DOM range.
const range = DomEditor.toSlateRange(editor, domRange, {
exactMatch: false,
suppressThrow: false,
})
return range
},
/**
* Find a Slate range from a DOM range or selection.
*/
toSlateRange(
editor: IDomEditor,
domRange: DOMRange | DOMStaticRange | DOMSelection,
options: {
exactMatch: T
suppressThrow: T
}
): T extends true ? Range | null : Range {
const { exactMatch, suppressThrow } = options
const el = isDOMSelection(domRange) ? domRange.anchorNode : domRange.startContainer
let anchorNode
let anchorOffset
let focusNode
let focusOffset
let isCollapsed
if (el) {
if (isDOMSelection(domRange)) {
anchorNode = domRange.anchorNode
anchorOffset = domRange.anchorOffset
focusNode = domRange.focusNode
focusOffset = domRange.focusOffset
// COMPAT: There's a bug in chrome that always returns `true` for
// `isCollapsed` for a Selection that comes from a ShadowRoot.
// (2020/08/08)
// https://bugs.chromium.org/p/chromium/issues/detail?id=447523
if (IS_CHROME && hasShadowRoot()) {
isCollapsed =
domRange.anchorNode === domRange.focusNode &&
domRange.anchorOffset === domRange.focusOffset
} else {
isCollapsed = domRange.isCollapsed
}
} else {
anchorNode = domRange.startContainer
anchorOffset = domRange.startOffset
focusNode = domRange.endContainer
focusOffset = domRange.endOffset
isCollapsed = domRange.collapsed
}
}
if (anchorNode == null || focusNode == null || anchorOffset == null || focusOffset == null) {
throw new Error(`Cannot resolve a Slate range from DOM range: ${domRange}`)
}
const anchor = DomEditor.toSlatePoint(editor, [anchorNode, anchorOffset], {
exactMatch,
suppressThrow,
})
if (!anchor) {
return null as T extends true ? Range | null : Range
}
const focus = isCollapsed
? anchor
: DomEditor.toSlatePoint(editor, [focusNode, focusOffset], { exactMatch, suppressThrow })
if (!focus) {
return null as T extends true ? Range | null : Range
}
// return { anchor, focus } as unknown as T extends true ? Range | null : Range
let range: Range = { anchor: anchor as Point, focus: focus as Point }
// if the selection is a hanging range that ends in a void
// and the DOM focus is an Element
// (meaning that the selection ends before the element)
// unhang the range to avoid mistakenly including the void
if (
Range.isExpanded(range) &&
Range.isForward(range) &&
isDOMElement(focusNode) &&
Editor.void(editor, { at: range.focus, mode: 'highest' })
) {
range = Editor.unhangRange(editor, range, { voids: true })
}
return range as unknown as T extends true ? Range | null : Range
},
/**
* Find a Slate point from a DOM selection's `domNode` and `domOffset`.
*/
toSlatePoint(
editor: IDomEditor,
domPoint: DOMPoint,
options: {
exactMatch: T
suppressThrow: T
}
): T extends true ? Point | null : Point {
const { exactMatch, suppressThrow } = options
const [nearestNode, nearestOffset] = exactMatch ? domPoint : normalizeDOMPoint(domPoint)
const parentNode = nearestNode.parentNode as DOMElement
let textNode: DOMElement | null = null
let offset = 0
if (parentNode) {
const voidNode = parentNode.closest('[data-slate-void="true"]')
let leafNode = parentNode.closest('[data-slate-leaf]')
let domNode: DOMElement | null = null
// Calculate how far into the text node the `nearestNode` is, so that we
// can determine what the offset relative to the text node is.
if (leafNode) {
textNode = leafNode.closest('[data-slate-node="text"]')!
const window = DomEditor.getWindow(editor)
const range = window.document.createRange()
range.setStart(textNode, 0)
range.setEnd(nearestNode, nearestOffset)
const contents = range.cloneContents()
const removals = [
...toArray(contents.querySelectorAll('[data-slate-zero-width]')),
...toArray(contents.querySelectorAll('[contenteditable=false]')),
]
removals.forEach(el => {
el!.parentNode!.removeChild(el)
})
// COMPAT: Edge has a bug where Range.prototype.toString() will
// convert \n into \r\n. The bug causes a loop when slate-react
// attempts to reposition its cursor to match the native position. Use
// textContent.length instead.
// https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/10291116/
offset = contents.textContent!.length
domNode = textNode
} else if (voidNode) {
// For void nodes, the element with the offset key will be a cousin, not an
// ancestor, so find it by going down from the nearest void parent.
leafNode = voidNode.querySelector('[data-slate-leaf]')!
// COMPAT: In read-only editors the leaf is not rendered.
if (!leafNode) {
offset = 1
} else {
textNode = leafNode.closest('[data-slate-node="text"]')!
domNode = leafNode
offset = domNode.textContent!.length
domNode.querySelectorAll('[data-slate-zero-width]').forEach(el => {
offset -= el.textContent!.length
})
}
}
if (
domNode &&
offset === domNode.textContent!.length &&
// COMPAT: If the parent node is a Slate zero-width space, editor is
// because the text node should have no characters. However, during IME
// composition the ASCII characters will be prepended to the zero-width
// space, so subtract 1 from the offset to account for the zero-width
// space character.
(parentNode.hasAttribute('data-slate-zero-width') ||
// COMPAT: In Firefox, `range.cloneContents()` returns an extra trailing '\n'
// when the document ends with a new-line character. This results in the offset
// length being off by one, so we need to subtract one to account for this.
(IS_FIREFOX && domNode.textContent?.endsWith('\n')))
) {
offset--
}
}
if (!textNode) {
if (suppressThrow) {
return null as T extends true ? Point | null : Point
}
throw new Error(`Cannot resolve a Slate point from DOM point: ${domPoint}`)
}
// COMPAT: If someone is clicking from one Slate editor into another,
// the select event fires twice, once for the old editor's `element`
// first, and then afterwards for the correct `element`. (2017/03/03)
const slateNode = DomEditor.toSlateNode(editor, textNode!)
const path = DomEditor.findPath(editor, slateNode)
return { path, offset } as T extends true ? Point | null : Point
},
hasRange(editor: IDomEditor, range: Range): boolean {
const { anchor, focus } = range
return Editor.hasPath(editor, anchor.path) && Editor.hasPath(editor, focus.path)
},
getNodeType(node: Node): string {
if (Element.isElement(node)) {
return node.type
}
return ''
},
checkNodeType(node: Node, type: string) {
return this.getNodeType(node) === type
},
getNodesStr(nodes: Node[]): string {
return nodes.map(node => Node.string(node)).join('')
},
getSelectedElems(editor: IDomEditor): Element[] {
const elems: Element[] = []
const nodeEntries = Editor.nodes(editor, { universal: true })
for (let nodeEntry of nodeEntries) {
const [node] = nodeEntry
if (Element.isElement(node)) elems.push(node)
}
return elems
},
getSelectedNodeByType(editor: IDomEditor, type: string): Node | null {
const [nodeEntry] = Editor.nodes(editor, {
match: n => this.checkNodeType(n, type),
universal: true,
})
if (nodeEntry == null) return null
return nodeEntry[0]
},
getSelectedTextNode(editor: IDomEditor): Node | null {
const [nodeEntry] = Editor.nodes(editor, {
match: n => Text.isText(n),
universal: true,
})
if (nodeEntry == null) return null
return nodeEntry[0]
},
isNodeSelected(editor: IDomEditor, node: Node): boolean {
const [nodeEntry] = Editor.nodes(editor, {
match: n => n === node,
universal: true,
})
if (nodeEntry == null) return false
const [n] = nodeEntry
if (n === node) return true
return false
},
isSelectionAtLineEnd(editor: IDomEditor, path: Path): boolean {
const { selection } = editor
if (!selection) return false
const isAtLineEnd =
Editor.isEnd(editor, selection.anchor, path) || Editor.isEnd(editor, selection.focus, path)
return isAtLineEnd
},
// 获取 textarea 实例
getTextarea(editor: IDomEditor): TextArea {
const textarea = EDITOR_TO_TEXTAREA.get(editor)
if (textarea == null) throw new Error('Cannot find textarea instance by editor')
return textarea
},
// 获取 toolbar 实例
getToolbar(editor: IDomEditor): Toolbar | null {
return EDITOR_TO_TOOLBAR.get(editor) || null
},
// 获取 hoverbar 实例
getHoverbar(editor: IDomEditor): HoverBar | null {
return EDITOR_TO_HOVER_BAR.get(editor) || null
},
// 格式化 editor content
normalizeContent(editor: IDomEditor) {
editor.children.forEach((node, index) => {
editor.normalizeNode([node, [index]])
})
},
/**
* 获取:距离触发 maxLength,还可以插入多少字符
* @param editor editor
*/
getLeftLengthOfMaxLength(editor: IDomEditor): number {
const { maxLength, onMaxLength } = editor.getConfig()
// 未设置 maxLength ,则返回 number 最大值
if (typeof maxLength !== 'number' || maxLength <= 0) return Infinity
const editorText = editor.getText().replace(/\r|\n|(\r\n)/g, '') // 去掉换行
const curLength = editorText.length
const leftLength = maxLength - curLength
if (leftLength <= 0) {
// 触发 maxLength 限制,不再继续插入文字
if (onMaxLength) onMaxLength(editor)
}
return leftLength
},
// 清理暴露的 text 节点(拼音输入时经常出现)
cleanExposedTexNodeInSelectionBlock(editor: IDomEditor) {
// 有时候全选删除新增的文本节点可能不在段落内,因此遍历textArea删除掉
const { $textArea } = DomEditor.getTextarea(editor)
const childNodes = $textArea?.[0].childNodes
if (childNodes) {
for (const node of Array.from(childNodes)) {
if (node.nodeType === 3) {
node.remove()
} else {
break
}
}
}
const nodeEntries = Editor.nodes(editor, {
match: n => {
if (Element.isElement(n)) {
if (!editor.isInline(n)) {
// 匹配 block element
return true
}
}
return false
},
universal: true,
})
for (let nodeEntry of nodeEntries) {
if (nodeEntry != null) {
const n = nodeEntry[0]
const elem = DomEditor.toDOMNode(editor, n)
// 只遍历 elem 范围,考虑性能
walkTextNodes(elem, (textNode, parent) => {
const $parent = $(parent)
if ($parent.attr('data-slate-string')) {
return // 正常的 text
}
if ($parent.attr('data-slate-zero-width')) {
return // 正常的 text
}
if ($parent.attr('data-w-e-reserve')) {
return // 故意保留的节点
}
// 暴露的 text node ,删除
parent.removeChild(textNode)
})
}
}
},
/**
* 是否是编辑器里最后一个元素
* @param editor editor
* @param node node
*/
isLastNode(editor: IDomEditor, node: Node) {
const editorChildren = editor.children || []
const editorChildrenLength = editorChildren.length
return editorChildren[editorChildrenLength - 1] === node
},
/**
* 生成空白 paragraph
*/
genEmptyParagraph(): Element {
return { type: 'paragraph', children: [{ text: '' }] }
},
/**
* 是否选中了 void node
* @param editor editor
*/
isSelectedVoidNode(editor: IDomEditor): boolean {
const voidNodes = Editor.nodes(editor, {
match: n => editor.isVoid(n as Element),
})
let len = 0
for (const n of voidNodes) {
len++
}
return len > 0
},
/**
* 选区是否在一个空行
* @param editor editor
*/
isSelectedEmptyParagraph(editor: IDomEditor) {
const { selection } = editor
if (selection == null) return false
if (Range.isExpanded(selection)) return false
const selectedNode = DomEditor.getSelectedNodeByType(editor, 'paragraph')
if (selectedNode === null) return false
const { children } = selectedNode as Element
if (children.length !== 1) return false
const { text } = children[0] as Text
if (text === '') return true
},
/**
* 当前 path 指向的 node ,是否是空的(无内容)
* @param editor editor
* @param path path
*/
isEmptyPath(editor: IDomEditor, path: Path): boolean {
const entry = Editor.node(editor, path)
if (entry == null) return false
const [node] = entry
const { children } = node as Element
if (children.length === 1) {
const { text } = children[0] as Text
if (text === '') return true // 内容为空
}
return false
},
}
================================================
FILE: packages/core/src/editor/interface.ts
================================================
/**
* @description editor interface
* @author wangfupeng
*/
import { Editor, Location, Node, Ancestor, Element } from 'slate'
import ee from 'event-emitter'
import { IEditorConfig, AlertType, ISingleMenuConfig } from '../config/interface'
import { IPositionStyle } from '../menus/interface'
import { DOMElement } from '../utils/dom'
export type ElementWithId = Element & { id: string }
/**
* 扩展 slate Editor 接口
*/
export interface IDomEditor extends Editor {
// data 相关(粘贴、拖拽等)
insertData: (data: DataTransfer) => void
setFragmentData: (data: Pick) => void
// config
getConfig: () => IEditorConfig
getMenuConfig: (menuKey: string) => ISingleMenuConfig
getAllMenuKeys: () => string[]
alert: (info: string, type: AlertType) => void
// 内容处理
handleTab: () => void
getHtml: () => string
getText: () => string
getSelectionText: () => string // 获取选区文字
getElemsByTypePrefix: (typePrefix: string) => ElementWithId[]
getElemsByType: (type: string, isPrefix?: boolean) => ElementWithId[]
getParentNode: (node: Node) => Ancestor | null
isEmpty: () => boolean
clear: () => void
dangerouslyInsertHtml: (html: string, isRecursive?: boolean) => void
setHtml: (html: string) => void
// dom 相关
id: string
isDestroyed: boolean
isFullScreen: boolean
focus: (isEnd?: boolean) => void
isFocused: () => boolean
blur: () => void
updateView: () => void
destroy: () => void
scrollToElem: (id: string) => void
showProgressBar: (progress: number) => void
hidePanelOrModal: () => void
enable: () => void
disable: () => void
isDisabled: () => boolean
toDOMNode: (node: Node) => HTMLElement
fullScreen: () => void
unFullScreen: () => void
getEditableContainer: () => DOMElement
// selection 相关
select: (at: Location) => void
deselect: () => void
move: (distance: number, reverse?: boolean) => void
moveReverse: (distance: number) => void
restoreSelection: () => void
getSelectionPosition: () => Partial
getNodePosition: (node: Node) => Partial
isSelectedAll: () => boolean
selectAll: () => void
// 自定义事件
on: (type: string, listener: ee.EventListener) => void
off: (type: string, listener: ee.EventListener) => void
once: (type: string, listener: ee.EventListener) => void
emit: (type: string, ...args: any[]) => void
// undo redo - 不用自己实现,使用 slate-history 扩展
undo?: () => void
redo?: () => void
}
================================================
FILE: packages/core/src/editor/plugins/with-config.ts
================================================
/**
* @description slate 插件 - config 相关
* @author wangfupeng
*/
import { Editor } from 'slate'
import { IDomEditor } from '../..'
import { EDITOR_TO_CONFIG } from '../../utils/weak-maps'
import { IEditorConfig, AlertType, ISingleMenuConfig } from '../../config/interface'
import { MENU_ITEM_FACTORIES } from '../../menus/register'
export const withConfig = (editor: T) => {
const e = editor as T & IDomEditor
e.getAllMenuKeys = (): string[] => {
const arr: string[] = []
for (let key in MENU_ITEM_FACTORIES) {
arr.push(key)
}
return arr
}
// 获取 editor 配置信息
e.getConfig = (): IEditorConfig => {
const config = EDITOR_TO_CONFIG.get(e)
if (config == null) throw new Error('Can not get editor config')
return config
}
// 获取 menu config
e.getMenuConfig = (menuKey: string): ISingleMenuConfig => {
const { MENU_CONF = {} } = e.getConfig()
return MENU_CONF[menuKey] || {}
}
// alert
e.alert = (info: string, type: AlertType = 'info') => {
const { customAlert } = e.getConfig()
if (customAlert) customAlert(info, type)
}
return e
}
================================================
FILE: packages/core/src/editor/plugins/with-content.ts
================================================
/**
* @description slate 插件 - content
* @author wangfupeng
*/
import { Editor, Node, Text, Path, Operation, Range, Transforms, Element, Descendant } from 'slate'
import { DomEditor } from '../dom-editor'
import { IDomEditor } from '../..'
import { EDITOR_TO_SELECTION, NODE_TO_KEY } from '../../utils/weak-maps'
import node2html from '../../to-html/node2html'
import { genElemId } from '../../render/helper'
import { Key } from '../../utils/key'
import $, { DOMElement, NodeType } from '../../utils/dom'
import { findCurrentLineRange } from '../../utils/line'
import { ElementWithId } from '../interface'
import { PARSE_ELEM_HTML_CONF, TEXT_TAGS } from '../../parse-html/index'
import parseElemHtml from '../../parse-html/parse-elem-html'
import { htmlToContent } from '../../create/helper'
import { IGNORE_TAGS } from '../../constants'
/**
* 把 elem 插入到编辑器
* @param editor editor
* @param elem slate elem
*/
function insertElemToEditor(editor: IDomEditor, elem: Element) {
if (editor.isInline(elem)) {
// inline elem 直接插入
editor.insertNode(elem)
// link 特殊处理,否则后面插入的文字全都在 a 里面 issue#4573
if (elem.type === 'link') editor.insertFragment([{ text: '' }])
} else {
// block elem ,另起一行插入 —— 重要
Transforms.insertNodes(editor, elem, { mode: 'highest' })
}
}
export const withContent = (editor: T) => {
const e = editor as T & IDomEditor
const { onChange, insertText, apply, deleteBackward } = e
e.insertText = (text: string) => {
const { readOnly } = e.getConfig()
if (readOnly) return
insertText(text)
}
// 重写 apply 方法
// apply 方法非常重要,它最终执行 operation https://docs.slatejs.org/concepts/05-operations
// operation 的接口定义参考 slate src/interfaces/operation.ts
e.apply = (op: Operation) => {
const matches: [Path, Key][] = []
switch (op.type) {
case 'insert_text':
case 'remove_text':
case 'set_node': {
for (const [node, path] of Editor.levels(e, { at: op.path })) {
// 在当前节点寻找
const key = DomEditor.findKey(e, node)
matches.push([path, key])
}
break
}
case 'insert_node':
case 'remove_node':
case 'merge_node':
case 'split_node': {
for (const [node, path] of Editor.levels(e, { at: Path.parent(op.path) })) {
// 在父节点寻找
const key = DomEditor.findKey(e, node)
matches.push([path, key])
}
break
}
case 'move_node': {
for (const [node, path] of Editor.levels(e, {
at: Path.common(Path.parent(op.path), Path.parent(op.newPath)),
})) {
const key = DomEditor.findKey(e, node)
matches.push([path, key])
}
break
}
}
// 执行原本的 apply - 重要!!!
apply(op)
// 绑定 node 和 key
for (const [path, key] of matches) {
const [node] = Editor.node(e, path)
NODE_TO_KEY.set(node, key)
}
}
e.deleteBackward = unit => {
if (unit !== 'line') {
return deleteBackward(unit)
}
if (editor.selection && Range.isCollapsed(editor.selection)) {
const parentBlockEntry = Editor.above(editor, {
match: n => Editor.isBlock(editor, n),
at: editor.selection,
})
if (parentBlockEntry) {
const [, parentBlockPath] = parentBlockEntry
const parentElementRange = Editor.range(editor, parentBlockPath, editor.selection.anchor)
const currentLineRange = findCurrentLineRange(e, parentElementRange)
if (!Range.isCollapsed(currentLineRange)) {
Transforms.delete(editor, { at: currentLineRange })
}
}
}
}
// 重写 onchange API
e.onChange = () => {
// 记录当前选区
const { selection } = e
if (selection != null) {
EDITOR_TO_SELECTION.set(e, selection)
}
// 触发配置的 change 事件
e.emit('change')
onChange()
}
// tab
e.handleTab = () => {
e.insertText(' ')
}
// 获取 html (去掉了格式化 2021.12.10)
e.getHtml = (): string => {
const { children = [] } = e
const html = children.map(child => node2html(child, e)).join('')
return html
}
// 获取 text
e.getText = (): string => {
const { children = [] } = e
return children.map(child => Node.string(child)).join('\n')
}
// 获取选区文字
e.getSelectionText = (): string => {
const { selection } = e
if (selection == null) return ''
return Editor.string(editor, selection)
}
// 根据 type 获取 elems
e.getElemsByType = (type: string, isPrefix = false): ElementWithId[] => {
const elems: ElementWithId[] = []
// 获取 editor 所有 nodes
const nodeEntries = Editor.nodes(e, {
at: [],
universal: true,
})
for (let nodeEntry of nodeEntries) {
const [node] = nodeEntry
if (Element.isElement(node)) {
// 判断 type (前缀 or 全等)
let flag = isPrefix ? node.type.indexOf(type) >= 0 : node.type === type
if (flag) {
const key = DomEditor.findKey(e, node)
const id = genElemId(key.id)
// node + id
elems.push({
...node,
id,
})
}
}
}
return elems
}
// 根据 type 前缀,获取 elems
e.getElemsByTypePrefix = (typePrefix: string): ElementWithId[] => {
return e.getElemsByType(typePrefix, true)
}
/**
* 判断 editor 是否为空(只有一个空 paragraph)
*/
e.isEmpty = () => {
const { children = [] } = e
if (children.length > 1) return false // >1 个顶级节点
const firstNode = children[0]
if (firstNode == null) return true // editor.children 空数组
if (Element.isElement(firstNode) && firstNode.type === 'paragraph') {
const { children: texts = [] } = firstNode
if (texts.length > 1) return false // >1 text node
const t = texts[0]
if (t == null) return true // 无 text 节点
if (Text.isText(t) && t.text === '') return true // 只有一个 text 且是空字符串
}
return false
}
/**
* 清空内容
*/
e.clear = () => {
const initialEditorValue: Node[] = [
{
type: 'paragraph',
children: [{ text: '' }],
},
]
Transforms.delete(e, {
at: {
anchor: Editor.start(e, []),
focus: Editor.end(e, []),
},
})
if (e.children.length === 0) {
Transforms.insertNodes(e, initialEditorValue)
}
}
e.getParentNode = (node: Node) => {
return DomEditor.getParentNode(e, node)
}
/**
* 插入 html (不保证语义完全正确),用于粘贴
* @param html html string
* @param isRecursive 是否递归调用(内部使用,使用者不要传参)
*/
e.dangerouslyInsertHtml = (html: string = '', isRecursive = false) => {
if (!html) return
// ------------- 把 html 转换为 DOM nodes -------------
const div = document.createElement('div')
div.innerHTML = html
let domNodes = Array.from(div.childNodes)
// 过滤一下,只保留 elem 和 text ,并却掉一些无用标签(如 style script 等)
domNodes = domNodes.filter(n => {
const { nodeType, nodeName } = n
// Text Node
if (nodeType === NodeType.TEXT_NODE) return true
// Element Node
if (nodeType === NodeType.ELEMENT_NODE) {
// 过滤掉忽略的 tag
if (IGNORE_TAGS.has(nodeName.toLowerCase())) return false
else return true
}
return false
})
if (domNodes.length === 0) return
// ------------- 把 DOM nodes 转换为 slate nodes ,并插入到编辑器 -------------
const { selection } = e
if (selection == null) return
let curEmptyParagraphPath: Path | null = null
// 是否当前选中了一个空 p (如果是,后面会删掉)
// 递归调用时不判断
if (DomEditor.isSelectedEmptyParagraph(e) && !isRecursive) {
const { focus } = selection
curEmptyParagraphPath = [focus.path[0]] // 只记录顶级 path 即可
}
div.setAttribute('hidden', 'true')
document.body.appendChild(div)
let insertedElemNum = 0 // 记录插入 elem 的数量 ( textNode 不算 )
domNodes.forEach(n => {
const { nodeType, nodeName, textContent = '' } = n
// ------ Text node ------
if (nodeType === NodeType.TEXT_NODE) {
if (!textContent || !textContent.trim()) return // 无内容的 Text
// 插入文本
//【注意】insertNode 和 insertText 有区别:后者会继承光标处的文本样式(如加粗);前者会加入纯文本,无样式;
e.insertNode({ text: textContent })
return
}
// ------ Element Node ------
if (nodeName === 'BR') {
e.insertText('\n') // 换行
return
}
// 判断当前的 el 是否是可识别的 tag
const el = n as DOMElement
let isParseMatch = false
if (TEXT_TAGS.includes(nodeName.toLowerCase())) {
// text elem,如
isParseMatch = true
} else {
for (let selector in PARSE_ELEM_HTML_CONF) {
if (el.matches(selector)) {
// 普通 elem,如