源码地址: https://github.com/Hufe921/canvas-editor
官方文档: https://hufe.club/canvas-editor-docs/guide/schema.html
之前写过一篇, vue2项目集成 cnavas-ediotr 富文本编辑器的文章(文章链接),
当时的 vue2 项目是通过 vue-cli 进行创建, 并且项目中并没有 ts 配置, 当需要我们集成 canvas-editor 时配置 ts ,相较于还是比较麻烦的.
这次通过 vite 创建 vue3 项目, 并且在创建项目的过程中选择好 ts 需要的配置;
注: 本文章只针对于 vite 创建的 vue3 项目, 对于通过 vue-cli 创建的 vue3 项目不适用.
Canvas-Editor环境配置
package.json
从devDependencies和 dependencies 属性中, 选择未具有的依赖; 然后 npm install
{
"name": "vue3-canvas-ediotr",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"devDependencies": {
"vue": "^3.4.31",
"@rollup/plugin-typescript": "^10.0.1",
"@types/node": "16.18.96",
"@types/prismjs": "^1.26.0",
"@typescript-eslint/eslint-plugin": "5.62.0",
"@typescript-eslint/parser": "5.62.0",
"@vitejs/plugin-vue": "^2.0.4",
"vite-plugin-vue-setup-extend": "^0.4.0",
"cypress": "13.6.0",
"cypress-file-upload": "^5.0.8",
"eslint": "7.32.0",
"simple-git-hooks": "^2.8.1",
"typescript": "4.8.4",
"vite": "^2.4.2",
"ts-loader": "^9.5.1",
"vite-plugin-css-injected-by-js": "^2.1.1",
"canvas-editor": "^1.0.0",
"@hufe921/canvas-editor": "^0.9.86"
},
"dependencies": {
"prismjs": "^1.27.0"
}
}
tsconfig.json
在原有的文件基础上直接进行替换 ( 根据你的需要,建议根据你本身项目tsconfig.json文件替换)
{
"compilerOptions": {
"target": "es5",
"module": "esnext",
"lib": ["es2015","dom"],
"strict": true,
"jsx": "preserve",
"importHelpers": true,
"moduleResolution": "node",
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"sourceMap": true,
"baseUrl": "..",
"paths": {
"@/*": ["src/*"]
}
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue",
"tests/**/*.ts",
"tests/**/*.tsx",
"tests/**/*.vue"],
"exclude": ["node_modules","dist"]
}
vite.config.ts
配置文件上 添加了 setup语法糖插件 和 将@表示地址, 指向 'src’文件夹; 建议同步一下
// @ts-ignore
import {defineConfig} from "vite";
// @ts-ignore
import vue from "@vitejs/plugin-vue";
// path config
import path from 'path'
// 导入安装的 vite-plugin-vue-setup-extend 插件
// @ts-ignore
import vueSetupExtend from "vite-plugin-vue-setup-extend";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue(), vueSetupExtend],
resolve:{
alias:{
'@' : path.resolve(__dirname,'src')
}
}
});
获取文件
将 canvas-editor 源码中的部分文件, 复制过来;
将复制的文件, 建议放到 src/view/canvas-editor目录下, 并且将 main.ts 改名为 canvas.ts; 而且canvas.ts代码进行了更改, 更改如(将main.ts文件内容全部替换成下面文件内容):
cnavas.ts
import './style.css'
import prism from 'prismjs'
// @ts-ignore
import Editor, {BlockType, Command, ControlType, EditorMode, EditorZone, ElementType, IBlock, ICatalogItem, IElement, KeyMap, ListStyle, ListType, PageMode, PaperDirection, RowFlex, TextDecorationStyle, TitleLevel, splitText} from './editor'
import { Dialog } from './components/dialog/Dialog'
import { formatPrismToken } from './utils/prism'
// @ts-ignore
import { debounce, nextTick, scrollIntoView } from './utils'
export default function Init (content:any) {
const isApple =
typeof navigator !== 'undefined' && /Mac OS X/.test(navigator.userAgent)
// 1. 初始化编辑器
const container = document.querySelector<HTMLDivElement>('.editor')!
const instance = new Editor(
container,
{
header: content.header,
main: content.main,
footer: content.footer,
},
{
margins: [50, 50, 50, 50],
watermark: {
data: '',
size: 120
}, // 水印
pageNumber: {
format: '第{pageNo}页/共{pageCount}页'
},
placeholder: {
data: '请输入正文'
},
zone: {
tipDisabled: false
},
maskMargin: [60, 0, 30, 0],// 菜单栏高度60,底部工具栏30为遮盖层,
} // 可选择项
)
console.log('实例: ', instance)
// cypress使用
Reflect.set(window, 'editor', instance)
// 菜单弹窗销毁
window.addEventListener(
'click',
evt => {
const visibleDom = document.querySelector('.visible')
if (!visibleDom || visibleDom.contains(<Node>evt.target)) return
visibleDom.classList.remove('visible')
},
{
capture: true
}
)
// 2. | 撤销 | 重做 | 格式刷 | 清除格式 |
const undoDom = document.querySelector<HTMLDivElement>('.menu-item__undo')!
undoDom.title = `撤销(${isApple ? '⌘' : 'Ctrl'}+Z)`
undoDom.onclick = function () {
console.log('undo')
instance.command.executeUndo()
}
const redoDom = document.querySelector<HTMLDivElement>('.menu-item__redo')!
redoDom.title = `重做(${isApple ? '⌘' : 'Ctrl'}+Y)`
redoDom.onclick = function () {
console.log('redo')
instance.command.executeRedo()
}
const painterDom = document.querySelector<HTMLDivElement>(
'.menu-item__painter'
)!
let isFirstClick = true
let painterTimeout: number
painterDom.onclick = function () {
if (isFirstClick) {
isFirstClick = false
painterTimeout = window.setTimeout(() => {
console.log('painter-click')
isFirstClick = true
instance.command.executePainter({
isDblclick: false
})
}, 200)
} else {
window.clearTimeout(painterTimeout)
}
}
painterDom.ondblclick = function () {
console.log('painter-dblclick')
isFirstClick = true
window.clearTimeout(painterTimeout)
instance.command.executePainter({
isDblclick: true
})
}
document.querySelector<HTMLDivElement>('.menu-item__format')!.onclick =
function () {
console.log('format')
instance.command.executeFormat()
}
// 3. | 字体 | 字体变大 | 字体变小 | 加粗 | 斜体 | 下划线 | 删除线 | 上标 | 下标 | 字体颜色 | 背景色 |
const fontDom = document.querySelector<HTMLDivElement>('.menu-item__font')!
const fontSelectDom = fontDom.querySelector<HTMLDivElement>('.select')!
const fontOptionDom = fontDom.querySelector<HTMLDivElement>('.options')!
fontDom.onclick = function () {
console.log('font')
fontOptionDom.classList.toggle('visible')
}
fontOptionDom.onclick = function (evt) {
const li = evt.target as HTMLLIElement
instance.command.executeFont(li.dataset.family!)
}
const sizeSetDom = document.querySelector<HTMLDivElement>('.menu-item__size')!
const sizeSelectDom = sizeSetDom.querySelector<HTMLDivElement>('.select')!
const sizeOptionDom = sizeSetDom.querySelector<HTMLDivElement>('.options')!
sizeSetDom.title = `设置字号`
sizeSetDom.onclick = function () {
console.log('size')
sizeOptionDom.classList.toggle('visible')
}
sizeOptionDom.onclick = function (evt) {
const li = evt.target as HTMLLIElement
instance.command.executeSize(Number(li.dataset.size!))
}
const sizeAddDom = document.querySelector<HTMLDivElement>(
'.menu-item__size-add'
)!
sizeAddDom.title = `增大字号(${isApple ? '⌘' : 'Ctrl'}+[)`
sizeAddDom.onclick = function () {
console.log('size-add')
instance.command.executeSizeAdd()
}
const sizeMinusDom = document.querySelector<HTMLDivElement>(
'.menu-item__size-minus'
)!
sizeMinusDom.title = `减小字号(${isApple ? '⌘' : 'Ctrl'}+])`
sizeMinusDom.onclick = function () {
console.log('size-minus')
instance.command.executeSizeMinus()
}
const boldDom = document.querySelector<HTMLDivElement>('.menu-item__bold')!
boldDom.title = `加粗(${isApple ? '⌘' : 'Ctrl'}+B)`
boldDom.onclick = function () {
console.log('bold')
instance.command.executeBold()
}
const italicDom =
document.querySelector<HTMLDivElement>('.menu-item__italic')!
italicDom.title = `斜体(${isApple ? '⌘' : 'Ctrl'}+I)`
italicDom.onclick = function () {
console.log('italic')
instance.command.executeItalic()
}
const underlineDom = document.querySelector<HTMLDivElement>(
'.menu-item__underline'
)!
underlineDom.title = `下划线(${isApple ? '⌘' : 'Ctrl'}+U)`
const underlineOptionDom =
underlineDom.querySelector<HTMLDivElement>('.options')!
underlineDom.querySelector<HTMLSpanElement>('.select')!.onclick =
function () {
underlineOptionDom.classList.toggle('visible')
}
underlineDom.querySelector<HTMLElement>('i')!.onclick = function () {
console.log('underline')
instance.command.executeUnderline()
underlineOptionDom.classList.remove('visible')
}
underlineDom.querySelector<HTMLUListElement>('ul')!.onmousedown = function (
evt
) {
const li = evt.target as HTMLLIElement
const decorationStyle = <TextDecorationStyle>li.dataset.decorationStyle
instance.command.executeUnderline({
style: decorationStyle
})
underlineOptionDom.classList.remove('visible')
}
const strikeoutDom = document.querySelector<HTMLDivElement>(
'.menu-item__strikeout'
)!
strikeoutDom.onclick = function () {
console.log('strikeout')
instance.command.executeStrikeout()
}
const superscriptDom = document.querySelector<HTMLDivElement>(
'.menu-item__superscript'
)!
superscriptDom.title = `上标(${isApple ? '⌘' : 'Ctrl'}+Shift+,)`
superscriptDom.onclick = function () {
console.log('superscript')
instance.command.executeSuperscript()
}
const subscriptDom = document.querySelector<HTMLDivElement>(
'.menu-item__subscript'
)!
subscriptDom.title = `下标(${isApple ? '⌘' : 'Ctrl'}+Shift+.)`
subscriptDom.onclick = function () {
console.log('subscript')
instance.command.executeSubscript()
}
const colorControlDom = document.querySelector<HTMLInputElement>('#color')!
colorControlDom.oninput = function () {
instance.command.executeColor(colorControlDom.value)
}
const colorDom = document.querySelector<HTMLDivElement>('.menu-item__color')!
const colorSpanDom = colorDom.querySelector('span')!
colorDom.onclick = function () {
console.log('color')
colorControlDom.click()
}
const highlightControlDom =
document.querySelector<HTMLInputElement>('#highlight')!
highlightControlDom.oninput = function () {
instance.command.executeHighlight(highlightControlDom.value)
}
const highlightDom = document.querySelector<HTMLDivElement>(
'.menu-item__highlight'
)!
const highlightSpanDom = highlightDom.querySelector('span')!
highlightDom.onclick = function () {
console.log('highlight')
highlightControlDom?.click()
}
const titleDom = document.querySelector<HTMLDivElement>('.menu-item__title')!
const titleSelectDom = titleDom.querySelector<HTMLDivElement>('.select')!
const titleOptionDom = titleDom.querySelector<HTMLDivElement>('.options')!
titleOptionDom.querySelectorAll('li').forEach((li, index) => {
li.title = `Ctrl+${isApple ? 'Option' : 'Alt'}+${index}`
})
titleDom.onclick = function () {
console.log('title')
titleOptionDom.classList.toggle('visible')
}
titleOptionDom.onclick = function (evt) {
const li = evt.target as HTMLLIElement
const level = <TitleLevel>li.dataset.level
instance.command.executeTitle(level || null)
}
const leftDom = document.querySelector<HTMLDivElement>('.menu-item__left')!
leftDom.title = `左对齐(${isApple ? '⌘' : 'Ctrl'}+L)`
leftDom.onclick = function () {
console.log('left')
instance.command.executeRowFlex(RowFlex.LEFT)
}
const centerDom =
document.querySelector<HTMLDivElement>('.menu-item__center')!
centerDom.title = `居中对齐(${isApple ? '⌘' : 'Ctrl'}+E)`
centerDom.onclick = function () {
console.log('center')
instance.command.executeRowFlex(RowFlex.CENTER)
}
const rightDom = document.querySelector<HTMLDivElement>('.menu-item__right')!
rightDom.title = `右对齐(${isApple ? '⌘' : 'Ctrl'}+R)`
rightDom.onclick = function () {
console.log('right')
instance.command.executeRowFlex(RowFlex.RIGHT)
}
const alignmentDom = document.querySelector<HTMLDivElement>(
'.menu-item__alignment'
)!
alignmentDom.title = `两端对齐(${isApple ? '⌘' : 'Ctrl'}+J)`
alignmentDom.onclick = function () {
console.log('alignment')
instance.command.executeRowFlex(RowFlex.ALIGNMENT)
}
const justifyDom = document.querySelector<HTMLDivElement>(
'.menu-item__justify'
)!
justifyDom.title = `分散对齐(${isApple ? '⌘' : 'Ctrl'}+Shift+J)`
justifyDom.onclick = function () {
console.log('justify')
instance.command.executeRowFlex(RowFlex.JUSTIFY)
}
const rowMarginDom = document.querySelector<HTMLDivElement>(
'.menu-item__row-margin'
)!
const rowOptionDom = rowMarginDom.querySelector<HTMLDivElement>('.options')!
rowMarginDom.onclick = function () {
console.log('row-margin')
rowOptionDom.classList.toggle('visible')
}
rowOptionDom.onclick = function (evt) {
const li = evt.target as HTMLLIElement
instance.command.executeRowMargin(Number(li.dataset.rowmargin!))
}
const listDom = document.querySelector<HTMLDivElement>('.menu-item__list')!
listDom.title = `列表(${isApple ? '⌘' : 'Ctrl'}+Shift+U)`
const listOptionDom = listDom.querySelector<HTMLDivElement>('.options')!
listDom.onclick = function () {
console.log('list')
listOptionDom.classList.toggle('visible')
}
listOptionDom.onclick = function (evt) {
const li = evt.target as HTMLLIElement
const listType = <ListType>li.dataset.listType || null
const listStyle = <ListStyle>(<unknown>li.dataset.listStyle)
instance.command.executeList(listType, listStyle)
}
// 4. | 表格 | 图片 | 超链接 | 分割线 | 水印 | 代码块 | 分隔符 | 控件 | 复选框 | LaTeX | 日期选择器
const tableDom = document.querySelector<HTMLDivElement>('.menu-item__table')!
const tablePanelContainer = document.querySelector<HTMLDivElement>(
'.menu-item__table__collapse'
)!
const tableClose = document.querySelector<HTMLDivElement>('.table-close')!
const tableTitle = document.querySelector<HTMLDivElement>('.table-select')!
const tablePanel = document.querySelector<HTMLDivElement>('.table-panel')!
// 绘制行列
const tableCellList: HTMLDivElement[][] = []
for (let i = 0; i < 10; i++) {
const tr = document.createElement('tr')
tr.classList.add('table-row')
const trCellList: HTMLDivElement[] = []
for (let j = 0; j < 10; j++) {
const td = document.createElement('td')
td.classList.add('table-cel')
tr.append(td)
trCellList.push(td)
}
tablePanel.append(tr)
tableCellList.push(trCellList)
}
let colIndex = 0
let rowIndex = 0
// 移除所有格选择
function removeAllTableCellSelect() {
tableCellList.forEach(tr => {
tr.forEach(td => td.classList.remove('active'))
})
}
// 设置标题内容
function setTableTitle(payload: string) {
tableTitle.innerText = payload
}
// 恢复初始状态
function recoveryTable() {
// 还原选择样式、标题、选择行列
removeAllTableCellSelect()
setTableTitle('插入')
colIndex = 0
rowIndex = 0
// 隐藏panel
tablePanelContainer.style.display = 'none'
}
tableDom.onclick = function () {
console.log('table')
tablePanelContainer!.style.display = 'block'
}
tablePanel.onmousemove = function (evt) {
const celSize = 16
const rowMarginTop = 10
const celMarginRight = 6
const { offsetX, offsetY } = evt
// 移除所有选择
removeAllTableCellSelect()
colIndex = Math.ceil(offsetX / (celSize + celMarginRight)) || 1
rowIndex = Math.ceil(offsetY / (celSize + rowMarginTop)) || 1
// 改变选择样式
tableCellList.forEach((tr, trIndex) => {
tr.forEach((td, tdIndex) => {
if (tdIndex < colIndex && trIndex < rowIndex) {
td.classList.add('active')
}
})
})
// 改变表格标题
setTableTitle(`${rowIndex}×${colIndex}`)
}
tableClose.onclick = function () {
recoveryTable()
}
tablePanel.onclick = function () {
// 应用选择
instance.command.executeInsertTable(rowIndex, colIndex)
recoveryTable()
}
const imageDom = document.querySelector<HTMLDivElement>('.menu-item__image')!
const imageFileDom = document.querySelector<HTMLInputElement>('#image')!
imageDom.onclick = function () {
imageFileDom.click()
}
imageFileDom.onchange = function () {
const file = imageFileDom.files![0]!
const fileReader = new FileReader()
fileReader.readAsDataURL(file)
fileReader.onload = function () {
// 计算宽高
const image = new Image()
const value = fileReader.result as string
image.src = value
image.onload = function () {
instance.command.executeImage({
value,
width: image.width,
height: image.height
})
imageFileDom.value = ''
}
}
}
const hyperlinkDom = document.querySelector<HTMLDivElement>(
'.menu-item__hyperlink'
)!
hyperlinkDom.onclick = function () {
console.log('hyperlink')
new Dialog({
title: '超链接',
data: [
{
type: 'text',
label: '文本',
name: 'name',
required: true,
placeholder: '请输入文本',
value: instance.command.getRangeText()
},
{
type: 'text',
label: '链接',
name: 'url',
required: true,
placeholder: '请输入链接'
}
],
onConfirm: payload => {
const name = payload.find(p => p.name === 'name')?.value
if (!name) return
const url = payload.find(p => p.name === 'url')?.value
if (!url) return
instance.command.executeHyperlink({
type: ElementType.HYPERLINK,
value: '',
url,
//@ts-ignore
valueList: splitText(name).map(n => ({
value: n,
size: 16
}))
})
}
})
}
const separatorDom = document.querySelector<HTMLDivElement>(
'.menu-item__separator'
)!
const separatorOptionDom =
separatorDom.querySelector<HTMLDivElement>('.options')!
separatorDom.onclick = function () {
console.log('separator')
separatorOptionDom.classList.toggle('visible')
}
separatorOptionDom.onmousedown = function (evt) {
let payload: number[] = []
const li = evt.target as HTMLLIElement
const separatorDash = li.dataset.separator?.split(',').map(Number)
if (separatorDash) {
const isSingleLine = separatorDash.every(d => d === 0)
if (!isSingleLine) {
payload = separatorDash
}
}
instance.command.executeSeparator(payload)
}
const pageBreakDom = document.querySelector<HTMLDivElement>(
'.menu-item__page-break'
)!
pageBreakDom.onclick = function () {
console.log('pageBreak')
instance.command.executePageBreak()
}
const watermarkDom = document.querySelector<HTMLDivElement>(
'.menu-item__watermark'
)!
const watermarkOptionDom =
watermarkDom.querySelector<HTMLDivElement>('.options')!
watermarkDom.onclick = function () {
console.log('watermark')
watermarkOptionDom.classList.toggle('visible')
}
watermarkOptionDom.onmousedown = function (evt) {
const li = evt.target as HTMLLIElement
const menu = li.dataset.menu!
watermarkOptionDom.classList.toggle('visible')
if (menu === 'add') {
new Dialog({
title: '水印',
data: [
{
type: 'text',
label: '内容',
name: 'data',
required: true,
placeholder: '请输入内容'
},
{
type: 'color',
label: '颜色',
name: 'color',
required: true,
value: '#AEB5C0'
},
{
type: 'number',
label: '字体大小',
name: 'size',
required: true,
value: '120'
}
],
onConfirm: payload => {
const nullableIndex = payload.findIndex(p => !p.value)
if (~nullableIndex) return
const watermark = payload.reduce((pre, cur) => {
pre[cur.name] = cur.value
return pre
}, <any>{})
instance.command.executeAddWatermark({
data: watermark.data,
color: watermark.color,
size: Number(watermark.size)
})
}
})
} else {
instance.command.executeDeleteWatermark()
}
}
const codeblockDom = document.querySelector<HTMLDivElement>(
'.menu-item__codeblock'
)!
codeblockDom.onclick = function () {
console.log('codeblock')
new Dialog({
title: '代码块',
data: [
{
type: 'textarea',
name: 'codeblock',
placeholder: '请输入代码',
width: 500,
height: 300
}
],
onConfirm: payload => {
const codeblock = payload.find(p => p.name === 'codeblock')?.value
if (!codeblock) return
const tokenList = prism.tokenize(codeblock, prism.languages.javascript)
const formatTokenList = formatPrismToken(tokenList)
const elementList: IElement[] = []
for (let i = 0; i < formatTokenList.length; i++) {
const formatToken = formatTokenList[i]
const tokenStringList = splitText(formatToken.content)
for (let j = 0; j < tokenStringList.length; j++) {
const value = tokenStringList[j]
const element: IElement = {
value
}
if (formatToken.color) {
element.color = formatToken.color
}
if (formatToken.bold) {
element.bold = true
}
if (formatToken.italic) {
element.italic = true
}
elementList.push(element)
}
}
elementList.unshift({
value: '\n'
})
instance.command.executeInsertElementList(elementList)
}
})
}
const controlDom = document.querySelector<HTMLDivElement>(
'.menu-item__control'
)!
const controlOptionDom = controlDom.querySelector<HTMLDivElement>('.options')!
controlDom.onclick = function () {
console.log('control')
controlOptionDom.classList.toggle('visible')
}
controlOptionDom.onmousedown = function (evt) {
controlOptionDom.classList.toggle('visible')
const li = evt.target as HTMLLIElement
const type = <ControlType>li.dataset.control
switch (type) {
case ControlType.TEXT:
new Dialog({
title: '文本控件',
data: [
{
type: 'text',
label: '占位符',
name: 'placeholder',
required: true,
placeholder: '请输入占位符'
},
{
type: 'text',
label: '默认值',
name: 'value',
placeholder: '请输入默认值'
}
],
onConfirm: payload => {
const placeholder = payload.find(
p => p.name === 'placeholder'
)?.value
if (!placeholder) return
const value = payload.find(p => p.name === 'value')?.value || ''
instance.command.executeInsertElementList([
{
type: ElementType.CONTROL,
value: '',
control: {
type,
value: value
? [
{
value
}
]
: null,
placeholder
}
}
])
}
})
break
case ControlType.SELECT:
new Dialog({
title: '列举控件',
data: [
{
type: 'text',
label: '占位符',
name: 'placeholder',
required: true,
placeholder: '请输入占位符'
},
{
type: 'text',
label: '默认值',
name: 'code',
placeholder: '请输入默认值'
},
{
type: 'textarea',
label: '值集',
name: 'valueSets',
required: true,
height: 100,
placeholder: `请输入值集JSON,例:\n[{\n"value":"有",\n"code":"98175"\n}]`
}
],
onConfirm: payload => {
const placeholder = payload.find(
p => p.name === 'placeholder'
)?.value
if (!placeholder) return
const valueSets = payload.find(p => p.name === 'valueSets')?.value
if (!valueSets) return
const code = payload.find(p => p.name === 'code')?.value
instance.command.executeInsertElementList([
{
type: ElementType.CONTROL,
value: '',
control: {
type,
code,
value: null,
placeholder,
valueSets: JSON.parse(valueSets)
}
}
])
}
})
break
case ControlType.CHECKBOX:
new Dialog({
title: '复选框控件',
data: [
{
type: 'text',
label: '默认值',
name: 'code',
placeholder: '请输入默认值,多个值以英文逗号分割'
},
{
type: 'textarea',
label: '值集',
name: 'valueSets',
required: true,
height: 100,
placeholder: `请输入值集JSON,例:\n[{\n"value":"有",\n"code":"98175"\n}]`
}
],
onConfirm: payload => {
const valueSets = payload.find(p => p.name === 'valueSets')?.value
if (!valueSets) return
const code = payload.find(p => p.name === 'code')?.value
instance.command.executeInsertElementList([
{
type: ElementType.CONTROL,
value: '',
control: {
type,
code,
value: null,
valueSets: JSON.parse(valueSets)
}
}
])
}
})
break
case ControlType.RADIO:
new Dialog({
title: '单选框控件',
data: [
{
type: 'text',
label: '默认值',
name: 'code',
placeholder: '请输入默认值'
},
{
type: 'textarea',
label: '值集',
name: 'valueSets',
required: true,
height: 100,
placeholder: `请输入值集JSON,例:\n[{\n"value":"有",\n"code":"98175"\n}]`
}
],
onConfirm: payload => {
const valueSets = payload.find(p => p.name === 'valueSets')?.value
if (!valueSets) return
const code = payload.find(p => p.name === 'code')?.value
instance.command.executeInsertElementList([
{
type: ElementType.CONTROL,
value: '',
control: {
type,
code,
value: null,
valueSets: JSON.parse(valueSets)
}
}
])
}
})
break
case ControlType.DATE:
new Dialog({
title: '日期控件',
data: [
{
type: 'text',
label: '占位符',
name: 'placeholder',
required: true,
placeholder: '请输入占位符'
},
{
type: 'text',
label: '默认值',
name: 'value',
placeholder: '请输入默认值'
},
{
type: 'select',
label: '日期格式',
name: 'dateFormat',
value: 'yyyy-MM-dd hh:mm:ss',
required: true,
options: [
{
label: 'yyyy-MM-dd hh:mm:ss',
value: 'yyyy-MM-dd hh:mm:ss'
},
{
label: 'yyyy-MM-dd',
value: 'yyyy-MM-dd'
}
]
}
],
onConfirm: payload => {
const placeholder = payload.find(
p => p.name === 'placeholder'
)?.value
if (!placeholder) return
const value = payload.find(p => p.name === 'value')?.value || ''
const dateFormat =
payload.find(p => p.name === 'dateFormat')?.value || ''
instance.command.executeInsertElementList([
{
type: ElementType.CONTROL,
value: '',
control: {
type,
dateFormat,
value: value
? [
{
value
}
]
: null,
placeholder
}
}
])
}
})
break
default:
break
}
}
const checkboxDom = document.querySelector<HTMLDivElement>(
'.menu-item__checkbox'
)!
checkboxDom.onclick = function () {
console.log('checkbox')
instance.command.executeInsertElementList([
{
type: ElementType.CHECKBOX,
checkbox: {
value: false
},
value: ''
}
])
}
const radioDom = document.querySelector<HTMLDivElement>('.menu-item__radio')!
radioDom.onclick = function () {
console.log('radio')
instance.command.executeInsertElementList([
{
type: ElementType.RADIO,
checkbox: {
value: false
},
value: ''
}
])
}
const latexDom = document.querySelector<HTMLDivElement>('.menu-item__latex')!
latexDom.onclick = function () {
console.log('LaTeX')
new Dialog({
title: 'LaTeX',
data: [
{
type: 'textarea',
height: 100,
name: 'value',
placeholder: '请输入LaTeX文本'
}
],
onConfirm: payload => {
const value = payload.find(p => p.name === 'value')?.value
if (!value) return
instance.command.executeInsertElementList([
{
type: ElementType.LATEX,
value
}
])
}
})
}
const dateDom = document.querySelector<HTMLDivElement>('.menu-item__date')!
const dateDomOptionDom = dateDom.querySelector<HTMLDivElement>('.options')!
dateDom.onclick = function () {
console.log('date')
dateDomOptionDom.classList.toggle('visible')
// 定位调整
const bodyRect = document.body.getBoundingClientRect()
const dateDomOptionRect = dateDomOptionDom.getBoundingClientRect()
if (dateDomOptionRect.left + dateDomOptionRect.width > bodyRect.width) {
dateDomOptionDom.style.right = '0px'
dateDomOptionDom.style.left = 'unset'
} else {
dateDomOptionDom.style.right = 'unset'
dateDomOptionDom.style.left = '0px'
}
// 当前日期
const date = new Date()
const year = date.getFullYear().toString()
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
const hour = date.getHours().toString().padStart(2, '0')
const minute = date.getMinutes().toString().padStart(2, '0')
const second = date.getSeconds().toString().padStart(2, '0')
const dateString = `${year}-${month}-${day}`
const dateTimeString = `${dateString} ${hour}:${minute}:${second}`
dateDomOptionDom.querySelector<HTMLLIElement>('li:first-child')!.innerText =
dateString
dateDomOptionDom.querySelector<HTMLLIElement>('li:last-child')!.innerText =
dateTimeString
}
dateDomOptionDom.onmousedown = function (evt) {
const li = evt.target as HTMLLIElement
const dateFormat = li.dataset.format!
dateDomOptionDom.classList.toggle('visible')
instance.command.executeInsertElementList([
{
type: ElementType.DATE,
value: '',
dateFormat,
valueList: [
{
value: li.innerText.trim()
}
]
}
])
}
const blockDom = document.querySelector<HTMLDivElement>('.menu-item__block')!
blockDom.onclick = function () {
console.log('block')
new Dialog({
title: '内容块',
data: [
{
type: 'select',
label: '类型',
name: 'type',
value: 'iframe',
required: true,
options: [
{
label: '网址',
value: 'iframe'
},
{
label: '视频',
value: 'video'
}
]
},
{
type: 'number',
label: '宽度',
name: 'width',
placeholder: '请输入宽度(默认页面内宽度)'
},
{
type: 'number',
label: '高度',
name: 'height',
required: true,
placeholder: '请输入高度'
},
{
type: 'input',
label: '地址',
name: 'src',
required: false,
placeholder: '请输入地址'
},
{
type: 'textarea',
label: 'HTML',
height: 100,
name: 'srcdoc',
required: false,
placeholder: '请输入HTML代码(仅网址类型有效)'
}
],
onConfirm: payload => {
const type = payload.find(p => p.name === 'type')?.value
if (!type) return
const width = payload.find(p => p.name === 'width')?.value
const height = payload.find(p => p.name === 'height')?.value
if (!height) return
// 地址或HTML代码至少存在一项
const src = payload.find(p => p.name === 'src')?.value
const srcdoc = payload.find(p => p.name === 'srcdoc')?.value
const block: IBlock = {
type: <BlockType>type
}
if (block.type === BlockType.IFRAME) {
if (!src && !srcdoc) return
block.iframeBlock = {
src,
srcdoc
}
} else if (block.type === BlockType.VIDEO) {
if (!src) return
block.videoBlock = {
src
}
}
const blockElement: IElement = {
type: ElementType.BLOCK,
value: '',
height: Number(height),
block
}
if (width) {
blockElement.width = Number(width)
}
instance.command.executeInsertElementList([blockElement])
}
})
}
// 5. | 搜索&替换 | 打印 |
const searchCollapseDom = document.querySelector<HTMLDivElement>(
'.menu-item__search__collapse'
)!
const searchInputDom = document.querySelector<HTMLInputElement>(
'.menu-item__search__collapse__search input'
)!
const replaceInputDom = document.querySelector<HTMLInputElement>(
'.menu-item__search__collapse__replace input'
)!
const searchDom =
document.querySelector<HTMLDivElement>('.menu-item__search')!
searchDom.title = `搜索与替换(${isApple ? '⌘' : 'Ctrl'}+F)`
const searchResultDom =
searchCollapseDom.querySelector<HTMLLabelElement>('.search-result')!
function setSearchResult() {
const result = instance.command.getSearchNavigateInfo()
if (result) {
const { index, count } = result
searchResultDom.innerText = `${index}/${count}`
} else {
searchResultDom.innerText = ''
}
}
searchDom.onclick = function () {
console.log('search')
searchCollapseDom.style.display = 'block'
const bodyRect = document.body.getBoundingClientRect()
const searchRect = searchDom.getBoundingClientRect()
const searchCollapseRect = searchCollapseDom.getBoundingClientRect()
if (searchRect.left + searchCollapseRect.width > bodyRect.width) {
searchCollapseDom.style.right = '0px'
searchCollapseDom.style.left = 'unset'
} else {
searchCollapseDom.style.right = 'unset'
}
searchInputDom.focus()
}
searchCollapseDom.querySelector<HTMLSpanElement>('span')!.onclick =
function () {
searchCollapseDom.style.display = 'none'
searchInputDom.value = ''
replaceInputDom.value = ''
instance.command.executeSearch(null)
setSearchResult()
}
searchInputDom.oninput = function () {
instance.command.executeSearch(searchInputDom.value || null)
setSearchResult()
}
searchInputDom.onkeydown = function (evt) {
if (evt.key === 'Enter') {
instance.command.executeSearch(searchInputDom.value || null)
setSearchResult()
}
}
searchCollapseDom.querySelector<HTMLButtonElement>('button')!.onclick =
function () {
const searchValue = searchInputDom.value
const replaceValue = replaceInputDom.value
if (searchValue && replaceValue && searchValue !== replaceValue) {
instance.command.executeReplace(replaceValue)
}
}
searchCollapseDom.querySelector<HTMLDivElement>('.arrow-left')!.onclick =
function () {
instance.command.executeSearchNavigatePre()
setSearchResult()
}
searchCollapseDom.querySelector<HTMLDivElement>('.arrow-right')!.onclick =
function () {
instance.command.executeSearchNavigateNext()
setSearchResult()
}
const printDom = document.querySelector<HTMLDivElement>('.menu-item__print')!
printDom.title = `打印(${isApple ? '⌘' : 'Ctrl'}+P)`
printDom.onclick = function () {
console.log('print')
instance.command.executePrint()
}
// 6. 目录显隐 | 页面模式 | 纸张缩放 | 纸张大小 | 纸张方向 | 页边距 | 全屏 | 设置
const editorOptionDom =
document.querySelector<HTMLDivElement>('.editor-option')!
editorOptionDom.onclick = function () {
const options = instance.command.getOptions()
new Dialog({
title: '编辑器配置',
data: [
{
type: 'textarea',
name: 'option',
width: 350,
height: 300,
required: true,
value: JSON.stringify(options, null, 2),
placeholder: '请输入编辑器配置'
}
],
onConfirm: payload => {
const newOptionValue = payload.find(p => p.name === 'option')?.value
if (!newOptionValue) return
const newOption = JSON.parse(newOptionValue)
instance.command.executeUpdateOptions(newOption)
}
})
}
async function updateCatalog() {
const catalog = await instance.command.getCatalog()
const catalogMainDom =
document.querySelector<HTMLDivElement>('.catalog__main')!
catalogMainDom.innerHTML = ''
if (catalog) {
const appendCatalog = (
parent: HTMLDivElement,
catalogItems: ICatalogItem[]
) => {
for (let c = 0; c < catalogItems.length; c++) {
const catalogItem = catalogItems[c]
const catalogItemDom = document.createElement('div')
catalogItemDom.classList.add('catalog-item')
// 渲染
const catalogItemContentDom = document.createElement('div')
catalogItemContentDom.classList.add('catalog-item__content')
const catalogItemContentSpanDom = document.createElement('span')
catalogItemContentSpanDom.innerText = catalogItem.name
catalogItemContentDom.append(catalogItemContentSpanDom)
// 定位
catalogItemContentDom.onclick = () => {
instance.command.executeLocationCatalog(catalogItem.id)
}
catalogItemDom.append(catalogItemContentDom)
if (catalogItem.subCatalog && catalogItem.subCatalog.length) {
appendCatalog(catalogItemDom, catalogItem.subCatalog)
}
// 追加
parent.append(catalogItemDom)
}
}
appendCatalog(catalogMainDom, catalog)
}
}
let isCatalogShow = true
const catalogDom = document.querySelector<HTMLElement>('.catalog')!
const catalogModeDom =
document.querySelector<HTMLDivElement>('.catalog-mode')!
const catalogHeaderCloseDom = document.querySelector<HTMLDivElement>(
'.catalog__header__close'
)!
const switchCatalog = () => {
isCatalogShow = !isCatalogShow
if (isCatalogShow) {
catalogDom.style.display = 'block'
updateCatalog()
} else {
catalogDom.style.display = 'none'
}
}
catalogModeDom.onclick = switchCatalog
catalogHeaderCloseDom.onclick = switchCatalog
const pageModeDom = document.querySelector<HTMLDivElement>('.page-mode')!
const pageModeOptionsDom =
pageModeDom.querySelector<HTMLDivElement>('.options')!
pageModeDom.onclick = function () {
pageModeOptionsDom.classList.toggle('visible')
}
pageModeOptionsDom.onclick = function (evt) {
const li = evt.target as HTMLLIElement
instance.command.executePageMode(<PageMode>li.dataset.pageMode!)
}
document.querySelector<HTMLDivElement>('.page-scale-percentage')!.onclick =
function () {
console.log('page-scale-recovery')
instance.command.executePageScaleRecovery()
}
document.querySelector<HTMLDivElement>('.page-scale-minus')!.onclick =
function () {
console.log('page-scale-minus')
instance.command.executePageScaleMinus()
}
document.querySelector<HTMLDivElement>('.page-scale-add')!.onclick =
function () {
console.log('page-scale-add')
instance.command.executePageScaleAdd()
}
// 纸张大小
const paperSizeDom = document.querySelector<HTMLDivElement>('.paper-size')!
const paperSizeDomOptionsDom =
paperSizeDom.querySelector<HTMLDivElement>('.options')!
paperSizeDom.onclick = function () {
paperSizeDomOptionsDom.classList.toggle('visible')
}
paperSizeDomOptionsDom.onclick = function (evt) {
const li = evt.target as HTMLLIElement
const paperType = li.dataset.paperSize!
const [width, height] = paperType.split('*').map(Number)
instance.command.executePaperSize(width, height)
// 纸张状态回显
paperSizeDomOptionsDom
.querySelectorAll('li')
.forEach(child => child.classList.remove('active'))
li.classList.add('active')
}
// 纸张方向
const paperDirectionDom =
document.querySelector<HTMLDivElement>('.paper-direction')!
const paperDirectionDomOptionsDom =
paperDirectionDom.querySelector<HTMLDivElement>('.options')!
paperDirectionDom.onclick = function () {
paperDirectionDomOptionsDom.classList.toggle('visible')
}
paperDirectionDomOptionsDom.onclick = function (evt) {
const li = evt.target as HTMLLIElement
const paperDirection = li.dataset.paperDirection!
instance.command.executePaperDirection(<PaperDirection>paperDirection)
// 纸张方向状态回显
paperDirectionDomOptionsDom
.querySelectorAll('li')
.forEach(child => child.classList.remove('active'))
li.classList.add('active')
}
// 页面边距
const paperMarginDom =
document.querySelector<HTMLDivElement>('.paper-margin')!
paperMarginDom.onclick = function () {
const [topMargin, rightMargin, bottomMargin, leftMargin] =
instance.command.getPaperMargin()
new Dialog({
title: '页边距',
data: [
{
type: 'text',
label: '上边距',
name: 'top',
required: true,
value: `${topMargin}`,
placeholder: '请输入上边距'
},
{
type: 'text',
label: '下边距',
name: 'bottom',
required: true,
value: `${bottomMargin}`,
placeholder: '请输入下边距'
},
{
type: 'text',
label: '左边距',
name: 'left',
required: true,
value: `${leftMargin}`,
placeholder: '请输入左边距'
},
{
type: 'text',
label: '右边距',
name: 'right',
required: true,
value: `${rightMargin}`,
placeholder: '请输入右边距'
}
],
onConfirm: payload => {
const top = payload.find(p => p.name === 'top')?.value
if (!top) return
const bottom = payload.find(p => p.name === 'bottom')?.value
if (!bottom) return
const left = payload.find(p => p.name === 'left')?.value
if (!left) return
const right = payload.find(p => p.name === 'right')?.value
if (!right) return
instance.command.executeSetPaperMargin([
Number(top),
Number(right),
Number(bottom),
Number(left)
])
}
})
}
// 全屏
const fullscreenDom = document.querySelector<HTMLDivElement>('.fullscreen')!
fullscreenDom.onclick = toggleFullscreen
window.addEventListener('keydown', evt => {
if (evt.key === 'F11') {
toggleFullscreen()
evt.preventDefault()
}
})
document.addEventListener('fullscreenchange', () => {
fullscreenDom.classList.toggle('exist')
})
function toggleFullscreen() {
console.log('fullscreen')
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen()
} else {
document.exitFullscreen()
}
}
// 7. 编辑器使用模式
let modeIndex = 0
const modeList = [
{
mode: EditorMode.EDIT,
name: '编辑模式'
},
{
mode: EditorMode.CLEAN,
name: '清洁模式'
},
{
mode: EditorMode.READONLY,
name: '只读模式'
},
{
mode: EditorMode.FORM,
name: '表单模式'
},
{
mode: EditorMode.PRINT,
name: '打印模式'
}
]
const modeElement = document.querySelector<HTMLDivElement>('.editor-mode')!
modeElement.onclick = function () {
// 模式选择循环
modeIndex === modeList.length - 1 ? (modeIndex = 0) : modeIndex++
// 设置模式
const { name, mode } = modeList[modeIndex]
modeElement.innerText = name
instance.command.executeMode(mode)
// 设置菜单栏权限视觉反馈
const isReadonly = mode === EditorMode.READONLY
const enableMenuList = ['search', 'print']
document.querySelectorAll<HTMLDivElement>('.menu-item>div').forEach(dom => {
const menu = dom.dataset.menu
isReadonly && (!menu || !enableMenuList.includes(menu))
? dom.classList.add('disable')
: dom.classList.remove('disable')
})
}
/* // 模拟批注
const commentDom = document.querySelector<HTMLDivElement>('.comment')!
async function updateComment() {
const groupIds = await instance.command.getGroupIds()
for (const comment of commentList) {
const activeCommentDom = commentDom.querySelector<HTMLDivElement>(
`.comment-item[data-id='${comment.id}']`
)
// 编辑器是否存在对应成组id
if (groupIds.includes(comment.id)) {
// 当前dom是否存在-不存在则追加
if (!activeCommentDom) {
const commentItem = document.createElement('div')
commentItem.classList.add('comment-item')
commentItem.setAttribute('data-id', comment.id)
commentItem.onclick = () => {
instance.command.executeLocationGroup(comment.id)
}
commentDom.append(commentItem)
// 选区信息
const commentItemTitle = document.createElement('div')
commentItemTitle.classList.add('comment-item__title')
commentItemTitle.append(document.createElement('span'))
const commentItemTitleContent = document.createElement('span')
commentItemTitleContent.innerText = comment.rangeText
commentItemTitle.append(commentItemTitleContent)
const closeDom = document.createElement('i')
closeDom.onclick = () => {
instance.command.executeDeleteGroup(comment.id)
}
commentItemTitle.append(closeDom)
commentItem.append(commentItemTitle)
// 基础信息
const commentItemInfo = document.createElement('div')
commentItemInfo.classList.add('comment-item__info')
const commentItemInfoName = document.createElement('span')
commentItemInfoName.innerText = comment.userName
const commentItemInfoDate = document.createElement('span')
commentItemInfoDate.innerText = comment.createdDate
commentItemInfo.append(commentItemInfoName)
commentItemInfo.append(commentItemInfoDate)
commentItem.append(commentItemInfo)
// 详细评论
const commentItemContent = document.createElement('div')
commentItemContent.classList.add('comment-item__content')
commentItemContent.innerText = comment.content
commentItem.append(commentItemContent)
commentDom.append(commentItem)
}
} else {
// 编辑器内不存在对应成组id则dom则移除
activeCommentDom?.remove()
}
}
}*/
// 8. 内部事件监听
// @ts-ignore
instance.listener.rangeStyleChange = function (payload) {
// 控件类型
payload.type === ElementType.SUBSCRIPT
? subscriptDom.classList.add('active')
: subscriptDom.classList.remove('active')
payload.type === ElementType.SUPERSCRIPT
? superscriptDom.classList.add('active')
: superscriptDom.classList.remove('active')
payload.type === ElementType.SEPARATOR
? separatorDom.classList.add('active')
: separatorDom.classList.remove('active')
separatorOptionDom
.querySelectorAll('li')
.forEach(li => li.classList.remove('active'))
if (payload.type === ElementType.SEPARATOR) {
const separator = payload.dashArray.join(',') || '0,0'
const curSeparatorDom = separatorOptionDom.querySelector<HTMLLIElement>(
`[data-separator='${separator}']`
)!
if (curSeparatorDom) {
curSeparatorDom.classList.add('active')
}
}
// 富文本
fontOptionDom
.querySelectorAll<HTMLLIElement>('li')
.forEach(li => li.classList.remove('active'))
const curFontDom = fontOptionDom.querySelector<HTMLLIElement>(
`[data-family='${payload.font}']`
)
if (curFontDom) {
fontSelectDom.innerText = curFontDom.innerText
fontSelectDom.style.fontFamily = payload.font
curFontDom.classList.add('active')
}
sizeOptionDom
.querySelectorAll<HTMLLIElement>('li')
.forEach(li => li.classList.remove('active'))
const curSizeDom = sizeOptionDom.querySelector<HTMLLIElement>(
`[data-size='${payload.size}']`
)
if (curSizeDom) {
sizeSelectDom.innerText = curSizeDom.innerText
curSizeDom.classList.add('active')
} else {
sizeSelectDom.innerText = `${payload.size}`
}
payload.bold
? boldDom.classList.add('active')
: boldDom.classList.remove('active')
payload.italic
? italicDom.classList.add('active')
: italicDom.classList.remove('active')
payload.underline
? underlineDom.classList.add('active')
: underlineDom.classList.remove('active')
payload.strikeout
? strikeoutDom.classList.add('active')
: strikeoutDom.classList.remove('active')
if (payload.color) {
colorDom.classList.add('active')
colorControlDom.value = payload.color
colorSpanDom.style.backgroundColor = payload.color
} else {
colorDom.classList.remove('active')
colorControlDom.value = '#000000'
colorSpanDom.style.backgroundColor = '#000000'
}
if (payload.highlight) {
highlightDom.classList.add('active')
highlightControlDom.value = payload.highlight
highlightSpanDom.style.backgroundColor = payload.highlight
} else {
highlightDom.classList.remove('active')
highlightControlDom.value = '#ffff00'
highlightSpanDom.style.backgroundColor = '#ffff00'
}
// 行布局
leftDom.classList.remove('active')
centerDom.classList.remove('active')
rightDom.classList.remove('active')
alignmentDom.classList.remove('active')
justifyDom.classList.remove('active')
if (payload.rowFlex && payload.rowFlex === 'right') {
rightDom.classList.add('active')
} else if (payload.rowFlex && payload.rowFlex === 'center') {
centerDom.classList.add('active')
} else if (payload.rowFlex && payload.rowFlex === 'alignment') {
alignmentDom.classList.add('active')
} else if (payload.rowFlex && payload.rowFlex === 'justify') {
justifyDom.classList.add('active')
} else {
leftDom.classList.add('active')
}
// 行间距
rowOptionDom
.querySelectorAll<HTMLLIElement>('li')
.forEach(li => li.classList.remove('active'))
const curRowMarginDom = rowOptionDom.querySelector<HTMLLIElement>(
`[data-rowmargin='${payload.rowMargin}']`
)!
curRowMarginDom.classList.add('active')
// 功能
payload.undo
? undoDom.classList.remove('no-allow')
: undoDom.classList.add('no-allow')
payload.redo
? redoDom.classList.remove('no-allow')
: redoDom.classList.add('no-allow')
payload.painter
? painterDom.classList.add('active')
: painterDom.classList.remove('active')
// 标题
titleOptionDom
.querySelectorAll<HTMLLIElement>('li')
.forEach(li => li.classList.remove('active'))
if (payload.level) {
const curTitleDom = titleOptionDom.querySelector<HTMLLIElement>(
`[data-level='${payload.level}']`
)!
titleSelectDom.innerText = curTitleDom.innerText
curTitleDom.classList.add('active')
} else {
titleSelectDom.innerText = '正文'
titleOptionDom.querySelector('li:first-child')!.classList.add('active')
}
// 列表
listOptionDom
.querySelectorAll<HTMLLIElement>('li')
.forEach(li => li.classList.remove('active'))
if (payload.listType) {
listDom.classList.add('active')
const listType = payload.listType
const listStyle =
payload.listType === ListType.OL ? ListStyle.DECIMAL : payload.listType
const curListDom = listOptionDom.querySelector<HTMLLIElement>(
`[data-list-type='${listType}'][data-list-style='${listStyle}']`
)
if (curListDom) {
curListDom.classList.add('active')
}
} else {
listDom.classList.remove('active')
}
// 批注
/* commentDom
.querySelectorAll<HTMLDivElement>('.comment-item')
.forEach(commentItemDom => {
commentItemDom.classList.remove('active')
})
if (payload.groupIds) {
const [id] = payload.groupIds
const activeCommentDom = commentDom.querySelector<HTMLDivElement>(
`.comment-item[data-id='${id}']`
)
if (activeCommentDom) {
activeCommentDom.classList.add('active')
scrollIntoView(commentDom, activeCommentDom)
}
}*/
}
// @ts-ignore
instance.listener.visiblePageNoListChange = function (payload) {
// @ts-ignore
const text = payload.map(i => i + 1).join('、')
document.querySelector<HTMLSpanElement>('.page-no-list')!.innerText = text
}
// @ts-ignore
instance.listener.pageSizeChange = function (payload) {
document.querySelector<HTMLSpanElement>(
'.page-size'
)!.innerText = `${payload}`
}
// @ts-ignore
instance.listener.intersectionPageNoChange = function (payload) {
document.querySelector<HTMLSpanElement>('.page-no')!.innerText = `${
payload + 1
}`
}
// @ts-ignore
instance.listener.pageScaleChange = function (payload) {
document.querySelector<HTMLSpanElement>(
'.page-scale-percentage'
)!.innerText = `${Math.floor(payload * 10 * 10)}%`
}
// @ts-ignore
instance.listener.controlChange = function (payload) {
const disableMenusInControlContext = [
'table',
'hyperlink',
'separator',
'page-break',
'control'
]
// 菜单操作权限
disableMenusInControlContext.forEach(menu => {
const menuDom = document.querySelector<HTMLDivElement>(
`.menu-item__${menu}`
)!
payload
? menuDom.classList.add('disable')
: menuDom.classList.remove('disable')
})
}
// @ts-ignore
instance.listener.pageModeChange = function (payload) {
const activeMode = pageModeOptionsDom.querySelector<HTMLLIElement>(
`[data-page-mode='${payload}']`
)!
pageModeOptionsDom
.querySelectorAll('li')
.forEach(li => li.classList.remove('active'))
activeMode.classList.add('active')
}
const handleContentChange = async function () {
// 字数
const wordCount = await instance.command.getWordCount()
document.querySelector<HTMLSpanElement>('.word-count')!.innerText = `${
wordCount || 0
}`
// 目录
if (isCatalogShow) {
nextTick(() => {
updateCatalog()
})
}
// 批注
/* nextTick(() => {
updateComment()
})*/
}
instance.listener.contentChange = debounce(handleContentChange, 200)
handleContentChange()
// @ts-ignore
instance.listener.saved = function (payload) {
console.log('elementList: ', payload)
}
// 9. 右键菜单注册
instance.register.contextMenuList([
])
// 10. 快捷键注册
instance.register.shortcutList([
{
key: KeyMap.P,
mod: true,
isGlobal: true,
callback: (command: Command) => {
command.executePrint()
}
},
{
key: KeyMap.F,
mod: true,
isGlobal: true,
callback: (command: Command) => {
const text = command.getRangeText()
searchDom.click()
if (text) {
searchInputDom.value = text
instance.command.executeSearch(text)
setSearchResult()
}
}
},
{
key: KeyMap.MINUS,
ctrl: true,
isGlobal: true,
callback: (command: Command) => {
command.executePageScaleMinus()
}
},
{
key: KeyMap.EQUAL,
ctrl: true,
isGlobal: true,
callback: (command: Command) => {
command.executePageScaleAdd()
}
},
{
key: KeyMap.ZERO,
ctrl: true,
isGlobal: true,
callback: (command: Command) => {
command.executePageScaleRecovery()
}
}
])
return {instance};
}
index.vue
是根据 cnavas-editor 源文件中的 index.html 更改而来, 更改如下(直接创建index.vue进行复制即可)
<template>
<div class="container">
<div class="menu" editor-component="menu">
<div class="menu-item disabled-btn" >
<div class="menu-item__undo">
<i></i>
</div>
<div class="menu-item__redo">
<i></i>
</div>
<div class="menu-item__painter" title="格式刷(双击可连续使用)">
<i></i>
</div>
<div class="menu-item__format" title="清除格式">
<i></i>
</div>
</div>
<div class="menu-divider "></div>
<div class="menu-item disabled-btn" >
<div class="menu-item__font">
<span class="select" title="字体">宋体</span>
<div class="options">
<ul>
<li data-family="宋体" style="font-family: '宋体';">宋体</li>
<li data-family="黑体" style="font-family: '黑体';">黑体</li>
<li data-family="Microsoft YaHei" style="font-family:'Microsoft YaHei';">微软雅黑</li>
<li data-family="Times New Roman" style="font-family:'Times New Roman';">Times New Roman</li>
<li data-family="华文宋体" style="font-family:'华文宋体';">华文宋体</li>
<li data-family="华文黑体" style="font-family:'华文黑体';">华文黑体</li>
<li data-family="华文仿宋" style="font-family:'华文仿宋';">华文仿宋</li>
<li data-family="华文楷体" style="font-family:'华文楷体';">华文楷体</li>
<li data-family="华文琥珀" style="font-family:'华文琥珀';">华文琥珀</li>
<li data-family="华文楷体" style="font-family:'华文楷体';">华文楷体</li>
<li data-family="华文隶书" style="font-family:'华文隶书';">华文隶书</li>
<li data-family="华文新魏" style="font-family:'华文新魏';">华文新魏</li>
<li data-family="华文行楷" style="font-family:'华文行楷';">华文行楷</li>
<li data-family="华文中宋" style="font-family:'华文中宋';">华文中宋</li>
<li data-family="华文彩云" style="font-family:'华文彩云';">华文彩云</li>
<li data-family="Arial" style="font-family:'Arial';">Arial</li>
<li data-family="Segoe UI" style="font-family:'Segoe UI';">Segoe UI</li>
<li data-family="Ink Free" style="font-family:'Ink Free';">Ink Free</li>
<li data-family="Fantasy" style="font-family:'Fantasy';">Fantasy</li>
</ul>
</div>
</div>
<div class="menu-item__size">
<span class="select" title="字体">小四</span>
<div class="options">
<ul>
<li data-size="56">初号</li>
<li data-size="48">小初</li>
<li data-size="34">一号</li>
<li data-size="32">小一</li>
<li data-size="29">二号</li>
<li data-size="24">小二</li>
<li data-size="21">三号</li>
<li data-size="20">小三</li>
<li data-size="18">四号</li>
<li data-size="16">小四</li>
<li data-size="14">五号</li>
<li data-size="12">小五</li>
<li data-size="10">六号</li>
<li data-size="8">小六</li>
<li data-size="7">七号</li>
<li data-size="6">八号</li>
<li data-size="5">5</li>
<li data-size="5.5">5.5</li>
<li data-size="6.5">6.5</li>
<li data-size="7.5">7.5</li>
<li data-size="8">8</li>
<li data-size="9">9</li>
<li data-size="10">10</li>
<li data-size="10.5">10.5</li>
<li data-size="11">11</li>
<li data-size="12">12</li>
<li data-size="14">14</li>
<li data-size="16">16</li>
<li data-size="18">18</li>
<li data-size="20">20</li>
<li data-size="22">22</li>
<li data-size="24">24</li>
<li data-size="26">26</li>
<li data-size="28">28</li>
<li data-size="36">36</li>
<li data-size="48">48</li>
<li data-size="50">50</li>
</ul>
</div>
</div>
<div class="menu-item__size-add">
<i></i>
</div>
<div class="menu-item__size-minus">
<i></i>
</div>
<div class="menu-item__bold">
<i></i>
</div>
<!-- 下划线 TODO -->
<div class="menu-item__italic">
<i></i>
</div>
<div class="menu-item__underline">
<i></i>
<span class="select"></span>
<div class="options">
<ul>
<li data-decoration-style='solid'>
<i></i>
</li>
<li data-decoration-style='double'>
<i></i>
</li>
<li data-decoration-style='dashed'>
<i></i>
</li>
<li data-decoration-style='dotted'>
<i></i>
</li>
<li data-decoration-style='wavy'>
<i></i>
</li>
</ul>
</div>
</div>
<div class="menu-item__strikeout" title="删除线(Ctrl+Shift+X)">
<i></i>
</div>
<div class="menu-item__superscript">
<i></i>
</div>
<div class="menu-item__subscript">
<i></i>
</div>
<div class="menu-item__color" title="字体颜色">
<i></i>
<span></span>
<input type="color" id="color" />
</div>
<div class="menu-item__highlight" title="高亮">
<i></i>
<span></span>
<input type="color" id="highlight">
</div>
</div>
<div class="menu-divider "></div>
<div class="menu-item disabled-btn">
<div class="menu-item__title">
<i></i>
<span class="select" title="切换标题">正文</span>
<div class="options">
<ul>
<li style="font-size:16px;">正文</li>
<li data-level="first" style="font-size:26px;">标题1</li>
<li data-level="second" style="font-size:24px;">标题2</li>
<li data-level="third" style="font-size:22px;">标题3</li>
<li data-level="fourth" style="font-size:20px;">标题4</li>
<li data-level="fifth" style="font-size:18px;">标题5</li>
<li data-level="sixth" style="font-size:16px;">标题6</li>
</ul>
</div>
</div>
<div class="menu-item__left">
<i></i>
</div>
<div class="menu-item__center">
<i></i>
</div>
<div class="menu-item__right">
<i></i>
</div>
<div class="menu-item__alignment">
<i></i>
</div>
<div class="menu-item__justify">
<i></i>
</div>
<div class="menu-item__row-margin">
<i title="行间距"></i>
<div class="options options_row-margin">
<ul>
<li data-rowmargin='0.5'>0.5</li>
<li data-rowmargin='0.75'>0.75</li>
<li data-rowmargin='1'>1</li>
<li data-rowmargin="1.25">1.25</li>
<li data-rowmargin="1.5">1.5</li>
<li data-rowmargin="1.75">1.75</li>
<li data-rowmargin="2">2</li>
<li data-rowmargin="2.5">2.5</li>
<li data-rowmargin="3">3</li>
</ul>
</div>
</div>
<div class="menu-item__list">
<i></i>
<div class="options">
<ul>
<li>
<label>取消列表</label>
</li>
<li data-list-type="ol" data-list-style='decimal'>
<label>有序列表:</label>
<ol>
<li>________</li>
</ol>
</li>
<li data-list-type="ul" data-list-style='checkbox'>
<label>复选框列表:</label>
<ul style="list-style-type: '☑️ ';">
<li>________</li>
</ul>
</li>
<li data-list-type="ul" data-list-style='disc'>
<label>实心圆点列表:</label>
<ul style="list-style-type: disc;">
<li>________</li>
</ul>
</li>
<li data-list-type="ul" data-list-style='circle'>
<label>空心圆点列表:</label>
<ul style="list-style-type: circle;">
<li>________</li>
</ul>
</li>
<li data-list-type="ul" data-list-style='square'>
<label>空心方块列表:</label>
<ul style="list-style-type: square;">
<li>________</li>
</ul>
</li>
</ul>
</div>
</div>
</div>
<div class="menu-divider "></div>
<div class="menu-item disabled-btn">
<div class="menu-item__table">
<i title="表格"></i>
</div>
<div class="menu-item__table__collapse">
<div class="table-close">×</div>
<div class="table-title">
<span class="table-select">插入</span>
<span>表格</span>
</div>
<div class="table-panel"></div>
</div>
<div class="menu-item__image">
<i title="图片"></i>
<input type="file" id="image" accept=".png, .jpg, .jpeg, .svg, .gif">
</div>
<div class="menu-item__hyperlink" >
<i title="超链接"></i>
</div>
<div class="menu-item__separator">
<i title="分割线"></i>
<div class="options options_separator">
<ul>
<li data-separator='0,0'>
<i></i>
</li>
<li data-separator="1,1">
<i></i>
</li>
<li data-separator="3,1">
<i></i>
</li>
<li data-separator="4,4">
<i></i>
</li>
<li data-separator="7,3,3,3">
<i></i>
</li>
<li data-separator="6,2,2,2,2,2">
<i></i>
</li>
</ul>
</div>
</div>
<div class="menu-item__watermark" >
<i title="水印(添加、删除)"></i>
<div class="options">
<ul>
<li data-menu="add">添加水印</li>
<li data-menu="delete">删除水印</li>
</ul>
</div>
</div>
<div class="menu-item__codeblock" title="代码块" >
<i></i>
</div>
<div class="menu-item__page-break" title="分页符">
<i></i>
</div>
<div class="menu-item__control">
<i title="控件"></i>
<div class="options">
<ul>
<li data-control='text'>文本</li>
<li data-control="select">列举</li>
<li data-control="date">日期</li>
<li data-control="checkbox">复选框</li>
<li data-control="radio">单选框</li>
</ul>
</div>
</div>
<div class="menu-item__checkbox" title="复选框">
<i></i>
</div>
<div class="menu-item__radio" title="单选框">
<i></i>
</div>
<div class="menu-item__latex" title="LateX">
<i></i>
</div>
<div class="menu-item__date">
<i title="日期"></i>
<div class="options options_date">
<ul>
<li data-format="yyyy-MM-dd"></li>
<li data-format="yyyy-MM-dd hh:mm:ss"></li>
</ul>
</div>
</div>
<div class="menu-item__block" title="内容块" >
<i></i>
</div>
</div>
<div class="menu-divider "></div>
<div class="menu-item">
<div class="menu-item__search" data-menu="search">
<i></i>
</div>
<div class="menu-item__search__collapse " data-menu="search">
<div class="menu-item__search__collapse__search">
<input type="text" />
<label class="search-result"></label>
<div class="arrow-left">
<i></i>
</div>
<div class="arrow-right">
<i></i>
</div>
<span>×</span>
</div>
<div class="menu-item__search__collapse__replace disabled-btn" >
<input type="text">
<button>替换</button>
</div>
</div>
<div class="menu-item__print" data-menu="print">
<i></i>
</div>
</div>
</div>
<!-- 目录进行隐藏 -->
<div class="catalog" editor-component="catalog" style="display: none">
<div class="catalog__header">
<span>目录</span>
<div class="catalog__header__close">
<i></i>
</div>
</div>
<div class="catalog__main"></div>
</div>
<div class="canvas-editor editor"></div>
<!-- 底部栏进行隐藏 -->
<div class="footer" editor-component="footer" >
<div>
<div class="catalog-mode" title="目录">
<i></i>
</div>
<div class="page-mode">
<i title="页面模式(分页、连页)"></i>
<div class="options">
<ul>
<li data-page-mode="paging" class="active">分页</li>
<li data-page-mode="continuity">连页</li>
</ul>
</div>
</div>
<span>可见页码:<span class="page-no-list">1</span></span>
<span>页面:<span class="page-no">1</span>/<span class="page-size">1</span></span>
<span>字数:<span class="word-count">0</span></span>
</div>
<div class="editor-mode" title="编辑模式(编辑、清洁、只读、表单)">编辑模式</div>
<div>
<div class="page-scale-minus" title="缩小(Ctrl+-)">
<i></i>
</div>
<span class="page-scale-percentage" title="显示比例(点击可复原Ctrl+0)">100%</span>
<div class="page-scale-add" title="放大(Ctrl+=)">
<i></i>
</div>
<div class="paper-size">
<i title="纸张类型"></i>
<div class="options">
<ul>
<li data-paper-size="794*1123" class="active">A4</li>
<li data-paper-size="1593*2251">A2</li>
<li data-paper-size="1125*1593">A3</li>
<li data-paper-size="565*796">A5</li>
<li data-paper-size="412*488">5号信封</li>
<li data-paper-size="450*866">6号信封</li>
<li data-paper-size="609*862">7号信封</li>
<li data-paper-size="862*1221">9号信封</li>
<li data-paper-size="813*1266">法律用纸</li>
<li data-paper-size="813*1054">信纸</li>
</ul>
</div>
</div>
<div class="paper-direction">
<i title="纸张方向"></i>
<div class="options">
<ul>
<li data-paper-direction="vertical" class="active">纵向</li>
<li data-paper-direction="horizontal">横向</li>
</ul>
</div>
</div>
<div class="paper-margin" title="页边距">
<i></i>
</div>
<div class="fullscreen" title="全屏显示">
<i></i>
</div>
<div class="editor-option" title="编辑器设置">
<i></i>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch, defineExpose } from 'vue';
import Init from './canvas.ts';
// Define props using defineProps
const props = defineProps({
parentContent: {
type: Object,
default: () => null
},
view: {
type: String,
default: null
}
});
// Reactive references
const instance = ref(null);
// Watchers
watch(() => props.parentContent, (newVal) => {
if (newVal) {
instance.value = Init(newVal);
}
}, { deep: true });
watch(() => props.view, (newVal) => {
if (newVal && instance.value) {
console.log("是否展示菜单: ", props.view);
console.log(instance.value.instance.command.getValue().data);
}
});
// Use emit with defineEmits
const emit = defineEmits(['save-content']);
// Methods
const saveContent = () => {
let content = {
data: {}
};
content.data = instance.value.instance.command.getValue().data;
emit('save-content', content);
};
defineExpose({
saveContent
})
</script>
<style>
@import url("./style.css");
@import url("./components/dialog/dialog.css");
</style>
<style scoped>
.container {
position: relative; /* 确保子元素可以相对于父元素进行定位 */
width: 100%; /* 设置父元素的宽度 */
height: calc(100vh - 120px);/* 设置父元素的高度 */
overflow: hidden; /* 根据需要设置溢出行为 */
text-align: center;
}
.menu {
position: fixed; /* 确保其相对于最近的已定位祖先元素 */
top: 0;
left: 0;
width: 100%; /* 确保菜单宽度与父元素一致 */
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); /* 添加阴影以示区别 */
margin-bottom: 10px;
}
.menu-item .options{
width:120px;
height:300px;
overflow-y: scroll;
}
.menu-item__separator .options_separator{
height: 160px;
}
.menu-item__date .options_date {
width: 200px;
height: 80px;
}
.menu-item__row-margin .options_row-margin{
height: 200px;
}
.canvas-editor {
position: static;
flex-direction: column;
overflow-y: scroll;
background-color: #f2f4f7;
height: 100%;
justify-content: center;
}
.disabled{
pointer-events: none;
opacity: 0.5;
}
</style>
测试案例
ParentTest.vue
模拟实现 父组件 ParentTest 和 子组件 CanvasEditor 实现通信, 以及数据的展示和存储;
<template>
<div class="parent-test">
<CanvasEditor
ref="canvasEditor"
:parentContent="parentContent"
:view="view"
@save-content="handleSaveCanvasEditorContent"
/>
<button
style="width: 80px; height: 40px; border: 2px solid #2b4b6b; margin-right: 20px; float: right"
@click="handleSaveContent"
>
保 存
</button>
</div>
</template>
<script setup lang="ts">
// @ts-ignore
import { ref, onMounted } from 'vue';
import CanvasEditor from './canvas-editor/index.vue';
// 存放父组件传递的数据
const parentContent = ref<any>(undefined);
// 存放子组件数据
const content = ref<any>(undefined);
// 标识符
const view = ref<string | undefined>(undefined);
// 初始化数据
onMounted(() => {
console.log("模拟父组件向后端请求数据, 传递给子组件");
getEditorContent();
view.value = "parent";
});
// 模拟后端获取数据的方法
const getEditorContent = () => {
parentContent.value = {
header: [
{
value: "父类传递的数据",
size: 12,
bold: false,
color: "rgb(33, 53, 71)",
italic: false,
},
],
main: [
{
value: "父类传递的数据 通过后端获取!",
size: 40,
bold: true,
},
],
};
};
// 组件引用
const canvasEditor = ref<InstanceType<typeof CanvasEditor> | null>(null);
// 保存内容方法
const handleSaveContent = () => {
// 访问子组件的方法
(canvasEditor.value as any).saveContent();
};
// 处理子组件传递的数据
const handleSaveCanvasEditorContent = (data: any) => {
console.log("从子组件接收到的数据:", data);
// 将data数据转换为 json 格式的数据, 方便入库处理
content.value = JSON.stringify(data);
console.log("转换后的数据 content 为: ", content.value);
};
</script>
<style scoped>
.parent-test{
width: 100%;
}
</style>