sp-editor.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655
  1. <template>
  2. <view class="sp-editor" :style="{ '--icon-size': iconSize, '--icon-columns': iconColumns }">
  3. <view class="sp-editor-toolbar" v-if="!readOnly" @tap="format">
  4. <view
  5. v-if="toolbarList.includes('header')"
  6. :class="formats.header === 1 ? 'ql-active' : ''"
  7. class="iconfont icon-format-header-1"
  8. title="标题"
  9. data-name="header"
  10. :data-value="1"
  11. ></view>
  12. <view
  13. v-if="toolbarList.includes('bold')"
  14. :class="formats.bold ? 'ql-active' : ''"
  15. class="iconfont icon-zitijiacu"
  16. title="加粗"
  17. data-name="bold"
  18. ></view>
  19. <view
  20. v-if="toolbarList.includes('italic')"
  21. :class="formats.italic ? 'ql-active' : ''"
  22. class="iconfont icon-zitixieti"
  23. title="斜体"
  24. data-name="italic"
  25. ></view>
  26. <view
  27. v-if="toolbarList.includes('underline')"
  28. :class="formats.underline ? 'ql-active' : ''"
  29. class="iconfont icon-zitixiahuaxian"
  30. title="下划线"
  31. data-name="underline"
  32. ></view>
  33. <view
  34. v-if="toolbarList.includes('strike')"
  35. :class="formats.strike ? 'ql-active' : ''"
  36. class="iconfont icon-zitishanchuxian"
  37. title="删除线"
  38. data-name="strike"
  39. ></view>
  40. <!-- #ifndef MP-BAIDU -->
  41. <view
  42. v-if="toolbarList.includes('alignLeft')"
  43. :class="formats.align === 'left' ? 'ql-active' : ''"
  44. class="iconfont icon-zuoduiqi"
  45. title="左对齐"
  46. data-name="align"
  47. data-value="left"
  48. ></view>
  49. <!-- #endif -->
  50. <view
  51. v-if="toolbarList.includes('alignCenter')"
  52. :class="formats.align === 'center' ? 'ql-active' : ''"
  53. class="iconfont icon-juzhongduiqi"
  54. title="居中对齐"
  55. data-name="align"
  56. data-value="center"
  57. ></view>
  58. <view
  59. v-if="toolbarList.includes('alignRight')"
  60. :class="formats.align === 'right' ? 'ql-active' : ''"
  61. class="iconfont icon-youduiqi"
  62. title="右对齐"
  63. data-name="align"
  64. data-value="right"
  65. ></view>
  66. <view
  67. v-if="toolbarList.includes('alignJustify')"
  68. :class="formats.align === 'justify' ? 'ql-active' : ''"
  69. class="iconfont icon-zuoyouduiqi"
  70. title="两端对齐"
  71. data-name="align"
  72. data-value="justify"
  73. ></view>
  74. <!-- #ifndef MP-BAIDU -->
  75. <view
  76. v-if="toolbarList.includes('lineHeight')"
  77. :class="formats.lineHeight ? 'ql-active' : ''"
  78. class="iconfont icon-line-height"
  79. title="行间距"
  80. data-name="lineHeight"
  81. data-value="2"
  82. ></view>
  83. <view
  84. v-if="toolbarList.includes('letterSpacing')"
  85. :class="formats.letterSpacing ? 'ql-active' : ''"
  86. class="iconfont icon-Character-Spacing"
  87. title="字间距"
  88. data-name="letterSpacing"
  89. data-value="2em"
  90. ></view>
  91. <view
  92. v-if="toolbarList.includes('marginTop')"
  93. :class="formats.marginTop ? 'ql-active' : ''"
  94. class="iconfont icon-722bianjiqi_duanqianju"
  95. title="段前距"
  96. data-name="marginTop"
  97. data-value="20px"
  98. ></view>
  99. <view
  100. v-if="toolbarList.includes('marginBottom')"
  101. :class="formats.marginBottom ? 'ql-active' : ''"
  102. class="iconfont icon-723bianjiqi_duanhouju"
  103. title="段后距"
  104. data-name="marginBottom"
  105. data-value="20px"
  106. ></view>
  107. <!-- #endif -->
  108. <!-- #ifndef MP-BAIDU -->
  109. <view
  110. v-if="toolbarList.includes('fontSize')"
  111. :class="formats.fontFamily ? 'ql-active' : ''"
  112. class="iconfont icon-font"
  113. title="字体"
  114. data-name="fontFamily"
  115. data-value="宋体"
  116. ></view>
  117. <view
  118. v-if="toolbarList.includes('fontSize')"
  119. :class="formats.fontSize === '24px' ? 'ql-active' : ''"
  120. class="iconfont icon-fontsize"
  121. title="字号"
  122. data-name="fontSize"
  123. data-value="24px"
  124. ></view>
  125. <!-- #endif -->
  126. <view
  127. v-if="toolbarList.includes('color')"
  128. :style="{ color: formats.color ? textColor : 'initial' }"
  129. class="iconfont icon-text_color"
  130. title="文字颜色"
  131. data-name="color"
  132. :data-value="textColor"
  133. ></view>
  134. <view
  135. v-if="toolbarList.includes('backgroundColor')"
  136. :style="{ color: formats.backgroundColor ? backgroundColor : 'initial' }"
  137. class="iconfont icon-fontbgcolor"
  138. title="背景颜色"
  139. data-name="backgroundColor"
  140. :data-value="backgroundColor"
  141. ></view>
  142. <view
  143. v-if="toolbarList.includes('date')"
  144. class="iconfont icon-date"
  145. title="日期"
  146. @tap="insertDate"
  147. ></view>
  148. <view
  149. v-if="toolbarList.includes('listCheck')"
  150. class="iconfont icon--checklist"
  151. title="待办"
  152. data-name="list"
  153. data-value="check"
  154. ></view>
  155. <view
  156. v-if="toolbarList.includes('listOrdered')"
  157. :class="formats.list === 'ordered' ? 'ql-active' : ''"
  158. class="iconfont icon-youxupailie"
  159. title="有序列表"
  160. data-name="list"
  161. data-value="ordered"
  162. ></view>
  163. <view
  164. v-if="toolbarList.includes('listBullet')"
  165. :class="formats.list === 'bullet' ? 'ql-active' : ''"
  166. class="iconfont icon-wuxupailie"
  167. title="无序列表"
  168. data-name="list"
  169. data-value="bullet"
  170. ></view>
  171. <view
  172. v-if="toolbarList.includes('divider')"
  173. class="iconfont icon-fengexian"
  174. title="分割线"
  175. @click="insertDivider"
  176. ></view>
  177. <view
  178. v-if="toolbarList.includes('indentDec')"
  179. class="iconfont icon-outdent"
  180. title="减少缩进"
  181. data-name="indent"
  182. data-value="-1"
  183. ></view>
  184. <view
  185. v-if="toolbarList.includes('indentInc')"
  186. class="iconfont icon-indent"
  187. title="增加缩进"
  188. data-name="indent"
  189. data-value="+1"
  190. ></view>
  191. <view
  192. v-if="toolbarList.includes('scriptSub')"
  193. :class="formats.script === 'sub' ? 'ql-active' : ''"
  194. class="iconfont icon-zitixiabiao"
  195. title="下标"
  196. data-name="script"
  197. data-value="sub"
  198. ></view>
  199. <view
  200. v-if="toolbarList.includes('scriptSuper')"
  201. :class="formats.script === 'super' ? 'ql-active' : ''"
  202. class="iconfont icon-zitishangbiao"
  203. title="上标"
  204. data-name="script"
  205. data-value="super"
  206. ></view>
  207. <view
  208. v-if="toolbarList.includes('direction')"
  209. :class="formats.direction === 'rtl' ? 'ql-active' : ''"
  210. class="iconfont icon-direction-rtl"
  211. title="文本方向"
  212. data-name="direction"
  213. data-value="rtl"
  214. ></view>
  215. <view
  216. v-if="toolbarList.includes('image')"
  217. class="iconfont icon-charutupian"
  218. title="图片"
  219. @tap="insertImage"
  220. ></view>
  221. <view
  222. v-if="toolbarList.includes('link')"
  223. class="iconfont icon-charulianjie"
  224. title="超链接"
  225. @tap="insertLink"
  226. ></view>
  227. <view
  228. v-if="toolbarList.includes('undo')"
  229. class="iconfont icon-undo"
  230. title="撤销"
  231. @tap="undo"
  232. ></view>
  233. <view
  234. v-if="toolbarList.includes('redo')"
  235. class="iconfont icon-redo"
  236. title="重做"
  237. @tap="redo"
  238. ></view>
  239. <view
  240. v-if="toolbarList.includes('removeFormat')"
  241. class="iconfont icon-clearedformat"
  242. title="清除格式"
  243. @tap="removeFormat"
  244. ></view>
  245. <view
  246. v-if="toolbarList.includes('clear')"
  247. class="iconfont icon-shanchu"
  248. title="清空"
  249. @tap="clear"
  250. ></view>
  251. <view
  252. v-if="toolbarList.includes('export')"
  253. class="iconfont icon-baocun"
  254. title="导出"
  255. @tap="exportHtml"
  256. ></view>
  257. </view>
  258. <!-- 自定义功能组件 -->
  259. <!-- 调色板 -->
  260. <color-picker
  261. v-if="toolbarList.includes('color') || toolbarList.includes('backgroundColor')"
  262. ref="colorPickerRef"
  263. :color="defaultColor"
  264. @confirm="confirmColor"
  265. ></color-picker>
  266. <!-- 添加链接的操作弹窗 -->
  267. <link-edit
  268. v-if="toolbarList.includes('link') && !readOnly"
  269. ref="linkEditRef"
  270. @confirm="confirmLink"
  271. ></link-edit>
  272. <view class="sp-editor-wrapper">
  273. <editor
  274. id="editor"
  275. class="ql-editor editor-container"
  276. :class="{ 'ql-image-overlay-none': readOnly }"
  277. show-img-size
  278. show-img-toolbar
  279. show-img-resize
  280. :placeholder="placeholder"
  281. :read-only="readOnly"
  282. @statuschange="onStatusChange"
  283. @ready="onEditorReady"
  284. @input="onEditorInput"
  285. ></editor>
  286. <!-- 只读蒙版 - 防止开启只读后仍能操作工具栏和图片删除 -->
  287. <!-- <view class="read-only-mask" v-if="readOnly"></view> -->
  288. </view>
  289. </view>
  290. </template>
  291. <script>
  292. import ColorPicker from './color-picker.vue'
  293. import LinkEdit from './link-edit.vue'
  294. import { addLink, linkFlag } from '../../utils'
  295. export default {
  296. components: {
  297. ColorPicker,
  298. LinkEdit
  299. },
  300. props: {
  301. placeholder: {
  302. type: String,
  303. default: '写点什么吧 ~'
  304. },
  305. // 是否只读
  306. readOnly: {
  307. type: Boolean,
  308. default: false
  309. },
  310. // 最大字数限制,-1不限
  311. maxlength: {
  312. type: Number,
  313. default: -1
  314. },
  315. // 工具栏配置
  316. toolbarConfig: {
  317. type: Object,
  318. default: () => {
  319. return {
  320. keys: [], // 要显示的工具,优先级最大
  321. excludeKeys: [], // 除这些指定的工具外,其他都显示
  322. iconSize: '18px', // 工具栏字体大小
  323. iconColumns: 10 // 工具栏列数
  324. }
  325. }
  326. }
  327. },
  328. watch: {
  329. toolbarConfig: {
  330. deep: true,
  331. immediate: true,
  332. handler(newToolbar) {
  333. /**
  334. * 若工具栏配置中keys存在,则以keys为准
  335. * 否则以excludeKeys向toolbarAllList中排查
  336. * 若keys与excludeKeys皆为空,则以toolbarAllList为准
  337. */
  338. if (newToolbar.keys?.length > 0) {
  339. this.toolbarList = newToolbar.keys
  340. } else {
  341. this.toolbarList =
  342. newToolbar.excludeKeys?.length > 0
  343. ? this.toolbarAllList.filter((item) => !newToolbar.excludeKeys.includes(item))
  344. : this.toolbarAllList
  345. }
  346. this.iconSize = newToolbar.iconSize || '18px'
  347. this.iconColumns = newToolbar.iconColumns || 10
  348. }
  349. }
  350. },
  351. data() {
  352. return {
  353. formats: {},
  354. textColor: '',
  355. backgroundColor: '',
  356. curColor: '',
  357. defaultColor: { r: 0, g: 0, b: 0, a: 1 }, // 调色板默认颜色
  358. iconSize: '20px', // 工具栏图标字体大小
  359. iconColumns: 10, // 工具栏列数
  360. toolbarList: [],
  361. toolbarAllList: [
  362. 'bold', // 加粗
  363. 'italic', // 斜体
  364. 'underline', // 下划线
  365. 'strike', // 删除线
  366. 'alignLeft', // 左对齐
  367. 'alignCenter', // 居中对齐
  368. 'alignRight', // 右对齐
  369. 'alignJustify', // 两端对齐
  370. 'lineHeight', // 行间距
  371. 'letterSpacing', // 字间距
  372. 'marginTop', // 段前距
  373. 'marginBottom', // 段后距
  374. 'fontFamily', // 字体
  375. 'fontSize', // 字号
  376. 'color', // 文字颜色
  377. 'backgroundColor', // 背景颜色
  378. 'date', // 日期
  379. 'listCheck', // 待办
  380. 'listOrdered', // 有序列表
  381. 'listBullet', // 无序列表
  382. 'indentInc', // 增加缩进
  383. 'indentDec', // 减少缩进
  384. 'divider', // 分割线
  385. 'header', // 标题
  386. 'scriptSub', // 下标
  387. 'scriptSuper', // 上标
  388. 'direction', // 文本方向
  389. 'image', // 图片
  390. 'link', // 超链接
  391. 'undo', // 撤销
  392. 'redo', // 重做
  393. 'removeFormat', // 清除格式
  394. 'clear', // 清空
  395. 'export' // 导出
  396. ]
  397. }
  398. },
  399. methods: {
  400. onEditorReady() {
  401. // #ifdef MP-BAIDU
  402. this.editorCtx = requireDynamicLib('editorLib').createEditorContext('editor')
  403. // #endif
  404. // #ifdef APP || MP-WEIXIN || H5
  405. uni
  406. .createSelectorQuery()
  407. .in(this)
  408. .select('#editor')
  409. .context((res) => {
  410. this.editorCtx = res.context
  411. this.$emit('init', this.editorCtx)
  412. })
  413. .exec()
  414. // #endif
  415. },
  416. undo() {
  417. this.editorCtx.undo()
  418. },
  419. redo() {
  420. this.editorCtx.redo()
  421. },
  422. format(e) {
  423. let { name, value } = e.target.dataset
  424. if (!name) return
  425. switch (name) {
  426. case 'color':
  427. case 'backgroundColor':
  428. this.curColor = name
  429. this.showPicker()
  430. break
  431. default:
  432. this.editorCtx.format(name, value)
  433. break
  434. }
  435. },
  436. showPicker() {
  437. switch (this.curColor) {
  438. case 'color':
  439. this.defaultColor = this.textColor
  440. ? this.$refs.colorPickerRef.hex2Rgb(this.textColor)
  441. : { r: 0, g: 0, b: 0, a: 1 }
  442. break
  443. case 'backgroundColor':
  444. this.defaultColor = this.backgroundColor
  445. ? this.$refs.colorPickerRef.hex2Rgb(this.backgroundColor)
  446. : { r: 0, g: 0, b: 0, a: 0 }
  447. break
  448. }
  449. this.$refs.colorPickerRef.open()
  450. },
  451. confirmColor(e) {
  452. switch (this.curColor) {
  453. case 'color':
  454. this.textColor = e.hex
  455. this.editorCtx.format('color', this.textColor)
  456. break
  457. case 'backgroundColor':
  458. this.backgroundColor = e.hex
  459. this.editorCtx.format('backgroundColor', this.backgroundColor)
  460. break
  461. }
  462. },
  463. onStatusChange(e) {
  464. if (e.detail.color) {
  465. this.textColor = e.detail.color
  466. }
  467. if (e.detail.backgroundColor) {
  468. this.backgroundColor = e.detail.backgroundColor
  469. }
  470. this.formats = e.detail
  471. },
  472. insertDivider() {
  473. this.editorCtx.insertDivider()
  474. },
  475. clear() {
  476. uni.showModal({
  477. title: '清空编辑器',
  478. content: '确定清空编辑器吗?',
  479. success: ({ confirm }) => {
  480. if (confirm) {
  481. this.editorCtx.clear()
  482. }
  483. }
  484. })
  485. },
  486. removeFormat() {
  487. uni.showModal({
  488. title: '文本格式化',
  489. content: '确定要清除所选择部分文本块格式吗?',
  490. showCancel: true,
  491. success: ({ confirm }) => {
  492. if (confirm) {
  493. this.editorCtx.removeFormat()
  494. }
  495. }
  496. })
  497. },
  498. insertDate() {
  499. const date = new Date()
  500. const formatDate = `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`
  501. this.editorCtx.insertText({ text: formatDate })
  502. },
  503. insertLink() {
  504. this.$refs.linkEditRef.open()
  505. },
  506. /**
  507. * 确认添加链接
  508. * @param {Object} e { text: '链接描述', href: '链接地址' }
  509. */
  510. confirmLink(e) {
  511. this.$refs.linkEditRef.close()
  512. this.$emit('addLink', e)
  513. addLink(this.editorCtx, e)
  514. },
  515. insertImage() {
  516. // #ifdef APP-PLUS || H5
  517. uni.chooseImage({
  518. // count: 1, // 默认9
  519. success: (res) => {
  520. const { tempFiles } = res
  521. // 将文件和编辑器示例抛出,由开发者自行上传和插入图片
  522. this.$emit('upinImage', tempFiles, this.editorCtx)
  523. },
  524. fail() {
  525. uni.showToast({
  526. title: '未授权访问相册权限,请授权后使用',
  527. icon: 'none'
  528. })
  529. }
  530. })
  531. // #endif
  532. // #ifdef MP-WEIXIN
  533. // 微信小程序从基础库 2.21.0 开始, wx.chooseImage 停止维护,请使用 uni.chooseMedia 代替。
  534. uni.chooseMedia({
  535. // count: 1, // 默认9
  536. success: (res) => {
  537. // 同上chooseImage处理
  538. const { tempFiles } = res
  539. this.$emit('upinImage', tempFiles, this.editorCtx)
  540. },
  541. fail() {
  542. uni.showToast({
  543. title: '未授权访问相册权限,请授权后使用',
  544. icon: 'none'
  545. })
  546. }
  547. })
  548. // #endif
  549. },
  550. onEditorInput(e) {
  551. // 注意不要使用getContents获取html和text,会导致重复触发onStatusChange从而失去toolbar工具的高亮状态
  552. // 复制粘贴的时候detail会为空,此时应当直接return
  553. if (Object.keys(e.detail).length <= 0) return
  554. const { html, text } = e.detail
  555. // 识别到标识立即return
  556. if (text.indexOf(linkFlag) !== -1) return
  557. const maxlength = parseInt(this.maxlength)
  558. const textStr = text.replace(/[ \t\r\n]/g, '')
  559. if (textStr.length > maxlength && maxlength != -1) {
  560. uni.showModal({
  561. content: `超过${maxlength}字数啦~`,
  562. confirmText: '确定',
  563. showCancel: false,
  564. success: () => {
  565. this.$emit('overMax', { html, text })
  566. }
  567. })
  568. } else {
  569. this.$emit('input', { html, text })
  570. }
  571. },
  572. // 导出
  573. exportHtml() {
  574. this.editorCtx.getContents({
  575. success: (res) => {
  576. this.$emit('exportHtml', res.html)
  577. }
  578. })
  579. }
  580. }
  581. }
  582. </script>
  583. <style lang="scss">
  584. @import '@/uni_modules/sp-editor/icons/editor-icon.css';
  585. .sp-editor {
  586. height: 100%;
  587. display: flex;
  588. flex-direction: column;
  589. position: relative;
  590. }
  591. .sp-editor-toolbar {
  592. box-sizing: border-box;
  593. padding: calc(var(--icon-size) / 4) 0;
  594. border-bottom: 1px solid #e4e4e4;
  595. font-family: 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif;
  596. display: grid;
  597. grid-template-columns: repeat(var(--icon-columns), 1fr);
  598. }
  599. .iconfont {
  600. display: flex;
  601. align-items: center;
  602. justify-content: center;
  603. width: 100%;
  604. height: calc(var(--icon-size) * 1.8);
  605. cursor: pointer;
  606. font-size: var(--icon-size);
  607. }
  608. .sp-editor-wrapper {
  609. flex: 1;
  610. overflow: hidden;
  611. position: relative;
  612. }
  613. .editor-container {
  614. padding: 8rpx 16rpx;
  615. box-sizing: border-box;
  616. width: 100%;
  617. height: 100%;
  618. font-size: 16px;
  619. line-height: 1.5;
  620. }
  621. .ql-image-overlay-none {
  622. ::v-deep .ql-image-overlay {
  623. pointer-events: none;
  624. opacity: 0;
  625. }
  626. }
  627. ::v-deep .ql-editor.ql-blank::before {
  628. font-style: normal;
  629. color: #cccccc;
  630. }
  631. ::v-deep .ql-container {
  632. min-height: unset;
  633. }
  634. .ql-active {
  635. color: #66ccff;
  636. }
  637. </style>