2026-04-23 2026-04-23 canvas-editor是一个基于HTML5 Canvas的富文本编辑器,支持所见即所得的编辑体验,支持图片上传、视频嵌入、格式排版等功能,可以用于笔记编辑、文档编辑、评论区富文本编辑等场景。 官方已经有vue3实现的demo插件 下面是我用vue2实现的demo 基于element123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418<template> <div class="Cityk-common-layout"> <div id="app"> <!-- 工具栏 --> <div class="editor-toolbar" editor-component="menu"> <el-button icon="el-icon-thumb" type="primary" @click="getAllData"> 保存 </el-button> <el-divider direction="vertical"></el-divider> <el-button icon="el-icon-back" title="撤销" @click="undo"></el-button> <el-button icon="el-icon-refresh" title="重做" @click="redo"></el-button> <el-button icon="el-icon-brush" title="格式刷" @click="executePainter(false)" @dblclick="executePainter(true)"></el-button> <el-button icon="el-icon-remove" @click="clearFormat" title="清除格式"></el-button> <el-divider direction="vertical"></el-divider> <el-dropdown @command="setFont"> <el-button size="small"> {{ fontName }} <i class="el-icon-arrow-down el-icon--right"></i> </el-button> <el-dropdown-menu slot="dropdown"> <el-dropdown-item v-for="item in fontList" :key="item.value" :command="item.value"> {{ item.label }} </el-dropdown-item> </el-dropdown-menu> </el-dropdown> <el-dropdown @command="setSize"> <el-button size="small"> {{ sizeName }} <i class="el-icon-arrow-down el-icon--right"></i> </el-button> <el-dropdown-menu slot="dropdown"> <el-dropdown-item v-for="item in sizeList" :key="item.value" :command="item.value"> {{ item.label }} </el-dropdown-item> </el-dropdown-menu> </el-dropdown> <el-button icon="el-icon-plus" title="加大字号" @click="sizeAdd"></el-button> <el-button icon="el-icon-minus" title="减小字号" @click="sizeSub"></el-button> <el-divider direction="vertical"></el-divider> <el-button icon="el-icon-mobile" @click="setBold" title="粗体" :class="{ 'btn-active': isBold }"></el-button> <el-button icon="el-icon-top-right" @click="setItalic" title="斜体" :class="{ 'btn-active': isItalic }"></el-button> <el-dropdown @command="setUnderline"> <el-button icon="el-icon-sort"></el-button> <el-dropdown-menu slot="dropdown"> <el-dropdown-item v-for="item in underlineList" :key="item.value" :command="item.value"> {{ item.label }} </el-dropdown-item> </el-dropdown-menu> </el-dropdown> <el-button icon="el-icon-remove" @click="setStrikeout" title="删除线"></el-button> <el-button icon="el-icon-top" @click="setSuperscript" title="上标"></el-button> <el-button icon="el-icon-bottom" @click="setSubscript" title="上下标"></el-button> <el-divider direction="vertical"></el-divider> <el-color-picker v-model="fontColor" @change="setColor" size="mini"></el-color-picker> <el-color-picker v-model="bgColor" @change="setHighlight" size="mini"></el-color-picker> <el-divider direction="vertical"></el-divider> <el-dropdown @command="setAlign"> <el-button icon="el-icon-rank"></el-button> <el-dropdown-menu slot="dropdown"> <el-dropdown-item v-for="item in alignList" :key="item.value" :command="item.value"> {{ item.label }} </el-dropdown-item> </el-dropdown-menu> </el-dropdown> <el-dropdown @command="setList"> <el-button icon="el-icon-coin"></el-button> <el-dropdown-menu slot="dropdown"> <el-dropdown-item v-for="item in listList" :key="item.value" :command="item.value"> {{ item.label }} </el-dropdown-item> </el-dropdown-menu> </el-dropdown> <el-divider direction="vertical"></el-divider> <el-button icon="el-icon-picture" @click="$refs.imgInput.click()"></el-button> <input ref="imgInput" type="file" hidden accept="image/*" @change="insertImage"> <el-button icon="el-icon-link" @click="insertLink"></el-button> <el-button icon="el-icon-minus" @click="insertSplitLine"></el-button> <el-button @click="addWatermark">水印</el-button> <el-button icon="el-icon-document" @click="insertPageBreak"></el-button> <!-- 搜索按钮 --> <el-button icon="el-icon-search" @click="openSearch" ref="searchBtn"></el-button> <el-button icon="el-icon-printer" @click="doPrint"></el-button> </div> <div v-if="showSearch" class="search-popup" :style="{ top: searchTop + 'px', left: searchLeft + 'px' }"> <div class="search-header"> <i class="el-icon-close" @click="closeSearch"></i> </div> <!-- 查找输入框(实时搜索 + 上下翻页) --> <el-input v-model="searchForm.key" placeholder="查找内容" @input="doSearch" @keyup.enter.native="doSearch" style="margin-bottom:8px"> <template slot="append"> <el-button size="small" icon="el-icon-top" @click="doSearchPrev"></el-button> <el-button size="small" icon="el-icon-bottom" @click="doSearchNext"></el-button> </template> </el-input> <!-- 🔥 官方同款:匹配数显示(对应 searchResultDom) --> <div class="search-result" ref="searchResultDom" style="text-align:right;font-size:12px;color:#606266;margin-bottom:8px"> {{ searchInfo.index }}/{{ searchInfo.count }} </div> <!-- 替换输入框 --> <el-input v-model="searchForm.replace" placeholder="替换内容" style="margin-bottom:8px"></el-input> <!-- 选项(完全对应官方) --> <div class="search-options"> <el-checkbox v-model="searchOptions.isRegex">正则</el-checkbox> <el-checkbox v-model="searchOptions.ignoreCase">忽略大小写</el-checkbox> <el-checkbox v-model="searchOptions.inSelection">选定内容内查找</el-checkbox> </div> <!-- 替换按钮 --> <div class="search-actions"> <el-button size="small" @click="doReplace">替换</el-button> <el-button size="small" @click="doReplaceAll">全部替换</el-button> </div> </div> <!-- 编辑器 --> <div class="canvas-editor" style="width:1000px;height:1000px;margin:20px auto;"></div> <!-- 超链接弹窗 --> <el-dialog title="插入超链接" :visible.sync="linkDialogVisible" append-to-body> <el-form label-width="80px"> <el-form-item label="文字"><el-input v-model="selectedLinkText" /></el-form-item> <el-form-item label="地址"><el-input v-model="linkUrl" /></el-form-item> </el-form> <template #footer> <el-button @click="linkDialogVisible = false">取消</el-button> <el-button type="primary" @click="handleLinkConfirm({ text: selectedLinkText, url: linkUrl })">确认</el-button> </template> </el-dialog> </div> </div></template><script>import Editor from '@hufe921/canvas-editor'export default { data() { return { instance: null, fontName: '微软雅黑', sizeName: '小四', fontColor: '#000', bgColor: '#ff0', showSearch: false, searchTop: 0, searchLeft: 0, searchForm: { key: '', replace: '' }, searchOptions: { isIgnoreCase: true }, fontList: [ { label: '微软雅黑', value: 'Microsoft YaHei' }, { label: '宋体', value: 'SimSun' }, { label: '黑体', value: 'SimHei' }, { label: '楷体', value: 'KaiTi' }, { label: '仿宋', value: 'FangSong' }, { label: '华文宋体', value: '华文宋体' }, { label: '华文黑体', value: '华文黑体' }, { label: '华文仿宋', value: '华文仿宋' }, { label: '华文楷体', value: '华文楷体' }, { label: '华文琥珀', value: '华文琥珀' }, { label: '华文隶书', value: '华文隶书' }, { label: '华文新魏', value: '华文新魏' }, { label: '华文行楷', value: '华文行楷' }, { label: '华文中宋', value: '华文中宋' }, { label: '华文彩云', value: '华文彩云' }, { label: 'Arial', value: 'Arial' }, { label: 'Segoe UI', value: 'Segoe UI' }, { label: 'Ink Free', value: 'Ink Free' }, { label: 'Fantasy', value: 'Fantasy' } ], sizeList: [ { label: '初号', value: 56 }, { label: '小初', value: 48 }, { label: '一号', value: 34 }, { label: '小一', value: 32 }, { label: '二号', value: 29 }, { label: '小二', value: 24 }, { label: '三号', value: 21 }, { label: '小三', value: 20 }, { label: '四号', value: 18 }, { label: '小四', value: 16 }, { label: '五号', value: 14 }, { label: '小五', value: 12 }, { label: '六号', value: 10 }, { label: '小六', value: 8 }, { label: '七号', value: 7 }, { label: '八号', value: 6 } ], underlineList: [ { label: '实线', value: 'solid' }, { label: '双线', value: 'double' }, { label: '波浪线', value: 'wavy' }, { label: '点线', value: 'dotted' }, { label: '虚线', value: 'dashed' }, ], listList: [ { label: '有序', value: 'ol,ordered' }, { label: '实心圆', value: 'ul,disc' }, { label: '复选', value: 'ul,checkbox' }, { label: '空心圆', value: 'ul,circle' }, { label: '空心块', value: 'ul,square' }, ], alignList: [ { label: '左对齐', value: 'left' }, { label: '居中对齐', value: 'center' }, { label: '右对齐', value: 'right' }, { label: '两端对齐', value: 'justify' }, ], linkDialogVisible: false, selectedLinkText: '', linkUrl: '', searchInfo: { index: 0, count: 0 }, isBold: false, isItalic: false, isUnderline: false, isStrikeout: false, isSuperscript: false, isSubscript: false, currentAlign: 'left', currentListType: null, currentListStyle: null, } }, mounted() { this.init() }, methods: { init() { this.instance = new Editor(document.querySelector('.canvas-editor'), { main: [{ value: '请输入内容\n' }], contextmenu: true }) this.instance.eventBus.on('rangeStyleChange', (payload) => { this.syncToolbar(payload) }) }, // 同步工具栏状态 // 官方原版:同步工具栏样式 syncToolbar(payload) { if (!payload || payload.type === null) return // ====================== // 1. 同步字体(正确回显) // ====================== if (payload.font) { const fontItem = this.fontList.find(item => item.value === payload.font) this.fontName = fontItem ? fontItem.label : payload.font } // ====================== // 2. 同步字号(区间匹配最小) // ====================== if (payload.size) { const validSizes = this.sizeList .filter(item => item.value <= payload.size) .sort((a, b) => b.value - a.value) this.sizeName = validSizes[0]?.label || '小四' } // ====================== // 3. 同步颜色(修复不生效) // ====================== if (payload.color !== undefined) { this.fontColor = payload.color || '#000000' } if (payload.highlight !== undefined) { this.bgColor = payload.highlight || '#ffffff' } // ====================== // 4. 同步文字样式 // ====================== this.isBold = !!payload.bold this.isItalic = !!payload.italic this.isUnderline = payload.underline === true this.isStrikeout = !!payload.strikeout this.isSuperscript = payload.type === 'superscript' this.isSubscript = payload.type === 'subscript' // ====================== // 5. 同步段落对齐 // ====================== if (payload.rowFlex !== undefined) { this.currentAlign = payload.rowFlex } // ====================== // 6. 同步列表 // ====================== if (payload.listType !== undefined) { this.currentListType = payload.listType this.currentListStyle = payload.listStyle || null } else { this.currentListType = null this.currentListStyle = null } }, executePainter(isDblclick) { this.instance.command.executePainter(isDblclick) }, clearFormat() { this.instance.command.executeFormat() }, undo() { this.instance.command.executeUndo() }, redo() { this.instance.command.executeRedo() }, setFont(val) { this.fontName = val; this.instance.command.executeFont(val) }, setSize(val) { const map = { 56: '初号', 48: '小初', 34: '一号', 16: '小四', 14: '五号' } this.sizeName = map[val] || val this.instance.command.executeSize(val) }, sizeAdd() { this.instance.command.executeSizeAdd() }, sizeSub() { this.instance.command.executeSizeMinus() }, setBold() { this.instance.command.executeBold() }, setItalic() { this.instance.command.executeItalic() }, setUnderline(val) { this.instance.command.executeUnderline({ style: val }) }, setStrikeout() { this.instance.command.executeStrikeout() }, setSuperscript() { this.instance.command.executeSuperscript() }, setSubscript() { this.instance.command.executeSubscript() }, setColor(val) { this.instance.command.executeColor(val) }, setHighlight(val) { this.instance.command.executeHighlight(val) }, setAlign(val) { this.instance.command.executeRowFlex(val) }, setList(val) { const [type, style] = val.split(',') this.instance.command.executeList(type, style) }, insertImage(e) { const file = e.target.files[0] if (!file) return const reader = new FileReader() reader.onload = (ev) => { const img = new Image() img.src = ev.target.result img.onload = () => { this.instance.command.executeImage({ value: ev.target.result, width: img.width, height: img.height }) } } reader.readAsDataURL(file) e.target.value = '' }, insertLink() { this.selectedLinkText = this.instance.command.getRangeText() || '' this.linkUrl = '' this.linkDialogVisible = true }, handleLinkConfirm(data) { const { text, url } = data this.linkDialogVisible = false if (!url) return const size = this.sizeList.find(s => s.label === this.sizeName)?.value || 16 this.instance.command.executeHyperlink({ type: 'hyperlink', url: url, valueList: text.split('').map(t => ({ value: t, size })) }) }, insertSplitLine() { this.instance.command.executeSeparator([1]) }, addWatermark() { this.instance.command.executeAddWatermark({ data: '内部资料' }) }, insertPageBreak() { this.instance.command.executePageBreak() }, openSearch() { const rect = this.$refs.searchBtn.$el.getBoundingClientRect() this.searchTop = rect.bottom + 10 this.searchLeft = rect.left - 200 this.showSearch = true }, closeSearch() { this.showSearch = false; this.instance.command.executeSearchExit(); this.setSearchResult() }, doSearch() { this.instance.command.executeSearch(this.searchForm.key, this.searchOptions); this.setSearchResult() }, doSearchPrev() { this.instance.command.executeSearchNavigatePre(); this.setSearchResult() }, doSearchNext() { this.instance.command.executeSearchNavigateNext(); this.setSearchResult() }, doReplace() { this.instance.command.executeReplace(this.searchForm.replace, { index: this.searchInfo.index == 0 ? 0 : this.searchInfo.index - 1 }); this.setSearchResult() }, doReplaceAll() { this.instance.command.executeReplace(this.searchForm.replace); this.setSearchResult() }, setSearchResult() { const result = this.instance.command.getSearchNavigateInfo() if (result) { this.searchInfo = result } else { this.searchInfo = { index: 0, count: 0 } } }, doPrint() { this.instance.command.executePrint() }, getAllData() { console.log('HTML', this.instance.command.getHTML()) console.log('TEXT', this.instance.command.getText()) this.$message.success('获取成功') } }}</script><style lang="scss" scoped>.editor-toolbar { display: flex; align-items: center; gap: 4px; padding: 10px; background: #f5f7fa; border: 1px solid #e4e7ed; flex-wrap: wrap;}::v-deep .el-divider--vertical { height: 20px; margin: 0 5px;}.search-popup { position: fixed; z-index: 9999; background: #fff; border-radius: 6px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); padding: 12px; width: 420px;}::v-deep .btn-active { background-color: #ecf5ff !important; color: #409eff !important; border-color: #d9ecff !important;}// 颜色选择器激活::v-deep .el-color-picker.btn-active { background: #ecf5ff; border-color: #d9ecff;}</style> 后一篇uniapp学习