RichTextBase2.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476
  1. <template>
  2. <div class="rich-text-base">
  3. <SelectGroup class="row">
  4. <Select
  5. style="width: 60%;"
  6. :value="richTextAttrs.fontname"
  7. search
  8. :searchLabel="lang.searchFont"
  9. @update:value="value => emitRichTextCommand('fontname', value as string)"
  10. :options="FONTS"
  11. >
  12. <template #icon>
  13. <IconFontSize />
  14. </template>
  15. </Select>
  16. <Select
  17. style="width: 40%;"
  18. :value="richTextAttrs.fontsize"
  19. search
  20. :searchLabel="lang.searchFontSize"
  21. @update:value="value => emitRichTextCommand('fontsize', value as string)"
  22. :options="fontSizeOptions.map(item => ({
  23. label: item, value: item
  24. }))"
  25. >
  26. <template #icon>
  27. <IconAddText />
  28. </template>
  29. </Select>
  30. </SelectGroup>
  31. <ButtonGroup class="row" passive>
  32. <Popover trigger="click" style="width: 30%;">
  33. <template #content>
  34. <ColorPicker
  35. :modelValue="richTextAttrs.color"
  36. @update:modelValue="value => emitRichTextCommand('color', value)"
  37. />
  38. </template>
  39. <TextColorButton first v-tooltip="lang.textColor" :color="richTextAttrs.color">
  40. <IconText />
  41. </TextColorButton>
  42. </Popover>
  43. <Popover trigger="click" style="width: 30%;">
  44. <template #content>
  45. <ColorPicker
  46. :modelValue="richTextAttrs.backcolor"
  47. @update:modelValue="value => emitRichTextCommand('backcolor', value)"
  48. />
  49. </template>
  50. <TextColorButton v-tooltip="lang.highlight" :color="richTextAttrs.backcolor">
  51. <IconHighLight />
  52. </TextColorButton>
  53. </Popover>
  54. <Button
  55. style="display: flex;align-items: center;"
  56. class="font-size-btn"
  57. v-tooltip="lang.fontSizeAdd"
  58. @click="emitRichTextCommand('fontsize-add')"
  59. ><IconFontSize />+</Button>
  60. <Button
  61. style="display: flex;align-items: center;"
  62. last
  63. class="font-size-btn"
  64. v-tooltip="lang.fontSizeReduce"
  65. @click="emitRichTextCommand('fontsize-reduce')"
  66. ><IconFontSize />-</Button>
  67. </ButtonGroup>
  68. <ButtonGroup class="row">
  69. <CheckboxButton
  70. style="flex: 1;"
  71. :checked="richTextAttrs.bold"
  72. v-tooltip="lang.bold"
  73. @click="emitRichTextCommand('bold')"
  74. ><IconTextBold /></CheckboxButton>
  75. <CheckboxButton
  76. style="flex: 1;"
  77. :checked="richTextAttrs.em"
  78. v-tooltip="lang.italic"
  79. @click="emitRichTextCommand('em')"
  80. ><IconTextItalic /></CheckboxButton>
  81. <CheckboxButton
  82. style="flex: 1;"
  83. :checked="richTextAttrs.underline"
  84. v-tooltip="lang.underline"
  85. @click="emitRichTextCommand('underline')"
  86. ><IconTextUnderline /></CheckboxButton>
  87. <CheckboxButton
  88. style="flex: 1;"
  89. :checked="richTextAttrs.strikethrough"
  90. v-tooltip="lang.strikethrough"
  91. @click="emitRichTextCommand('strikethrough')"
  92. ><IconStrikethrough /></CheckboxButton>
  93. </ButtonGroup>
  94. <!-- <ButtonGroup class="row">
  95. <CheckboxButton
  96. style="flex: 1;"
  97. :checked="richTextAttrs.superscript"
  98. v-tooltip="'上标'"
  99. @click="emitRichTextCommand('superscript')"
  100. >A²</CheckboxButton>
  101. <CheckboxButton
  102. style="flex: 1;"
  103. :checked="richTextAttrs.subscript"
  104. v-tooltip="'下标'"
  105. @click="emitRichTextCommand('subscript')"
  106. >A₂</CheckboxButton>
  107. <CheckboxButton
  108. style="flex: 1;"
  109. :checked="richTextAttrs.code"
  110. v-tooltip="'行内代码'"
  111. @click="emitRichTextCommand('code')"
  112. ><IconCode /></CheckboxButton>
  113. <CheckboxButton
  114. style="flex: 1;"
  115. :checked="richTextAttrs.blockquote"
  116. v-tooltip="'引用'"
  117. @click="emitRichTextCommand('blockquote')"
  118. ><IconQuote /></CheckboxButton>
  119. </ButtonGroup>
  120. <ButtonGroup class="row" passive>
  121. <Popover trigger="click" v-model:value="AIPopoverVisible" style="width: 25%;">
  122. <template #content>
  123. <PopoverMenuItem center @click="execAI('美化改写')">美化</PopoverMenuItem>
  124. <PopoverMenuItem center @click="execAI('扩写丰富')">扩写</PopoverMenuItem>
  125. <PopoverMenuItem center @click="execAI('精简提炼')">精简</PopoverMenuItem>
  126. </template>
  127. <CheckboxButton
  128. first
  129. style="width: 100%;"
  130. v-tooltip="'AI辅助'"
  131. ><span :class="{ 'ai-loading': isAIWriting }">{{ isAIWriting ? '' : 'AI' }}</span></CheckboxButton>
  132. </Popover>
  133. <CheckboxButton
  134. style="flex: 1;"
  135. v-tooltip="'清除格式'"
  136. @click="emitRichTextCommand('clear')"
  137. ><IconFormat /></CheckboxButton>
  138. <CheckboxButton
  139. style="flex: 1;"
  140. :checked="!!textFormatPainter"
  141. v-tooltip="'格式刷(双击连续使用)'"
  142. @click="toggleTextFormatPainter()"
  143. @dblclick="toggleTextFormatPainter(true)"
  144. ><IconFormatBrush /></CheckboxButton>
  145. <Popover placement="bottom-end" trigger="click" v-model:value="linkPopoverVisible" style="width: 25%;">
  146. <template #content>
  147. <div class="link-popover">
  148. <Input v-model:value="link" placeholder="请输入超链接" />
  149. <div class="btns">
  150. <Button size="small" :disabled="!richTextAttrs.link" @click="removeLink()" style="margin-right: 5px;">移除</Button>
  151. <Button size="small" type="primary" @click="updateLink(link)">确认</Button>
  152. </div>
  153. </div>
  154. </template>
  155. <CheckboxButton
  156. last
  157. style="width: 100%;"
  158. :checked="!!richTextAttrs.link"
  159. v-tooltip="'超链接'"
  160. @click="openLinkPopover()"
  161. ><IconLinkOne /></CheckboxButton>
  162. </Popover>
  163. </ButtonGroup>
  164. <Divider />
  165. <RadioGroup
  166. class="row"
  167. button-style="solid"
  168. :value="richTextAttrs.align"
  169. @update:value="value => emitRichTextCommand('align', value)"
  170. >
  171. <RadioButton value="left" v-tooltip="'左对齐'" style="flex: 1;"><IconAlignTextLeft /></RadioButton>
  172. <RadioButton value="center" v-tooltip="'居中'" style="flex: 1;"><IconAlignTextCenter /></RadioButton>
  173. <RadioButton value="right" v-tooltip="'右对齐'" style="flex: 1;"><IconAlignTextRight /></RadioButton>
  174. <RadioButton value="justify" v-tooltip="'两端对齐'" style="flex: 1;"><IconAlignTextBoth /></RadioButton>
  175. </RadioGroup>
  176. <div class="row" passive>
  177. <ButtonGroup style="flex: 1;">
  178. <Button
  179. first
  180. :type="richTextAttrs.bulletList ? 'primary' : 'default'"
  181. style="flex: 1;"
  182. v-tooltip="'项目符号'"
  183. @click="emitRichTextCommand('bulletList')"
  184. ><IconList /></Button>
  185. <Popover trigger="click" v-model:value="bulletListPanelVisible">
  186. <template #content>
  187. <div class="list-wrap">
  188. <ul class="list"
  189. v-for="item in bulletListStyleTypeOption"
  190. :key="item"
  191. :style="{ listStyleType: item }"
  192. @click="emitRichTextCommand('bulletList', item)"
  193. >
  194. <li class="list-item" v-for="key in 3" :key="key"><span></span></li>
  195. </ul>
  196. </div>
  197. </template>
  198. <Button last class="popover-btn"><IconDown /></Button>
  199. </Popover>
  200. </ButtonGroup>
  201. <div style="width: 10px;"></div>
  202. <ButtonGroup style="flex: 1;" passive>
  203. <Button
  204. first
  205. :type="richTextAttrs.orderedList ? 'primary' : 'default'"
  206. style="flex: 1;"
  207. v-tooltip="'编号'"
  208. @click="emitRichTextCommand('orderedList')"
  209. ><IconOrderedList /></Button>
  210. <Popover trigger="click" v-model:value="orderedListPanelVisible">
  211. <template #content>
  212. <div class="list-wrap">
  213. <ul class="list"
  214. v-for="item in orderedListStyleTypeOption"
  215. :key="item"
  216. :style="{ listStyleType: item }"
  217. @click="emitRichTextCommand('orderedList', item)"
  218. >
  219. <li class="list-item" v-for="key in 3" :key="key"><span></span></li>
  220. </ul>
  221. </div>
  222. </template>
  223. <Button last class="popover-btn"><IconDown /></Button>
  224. </Popover>
  225. </ButtonGroup>
  226. </div>
  227. <div class="row">
  228. <ButtonGroup style="flex: 1;" passive>
  229. <Button first style="flex: 1;" v-tooltip="'减小段落缩进'" @click="emitRichTextCommand('indent', '-1')"><IconIndentLeft /></Button>
  230. <Popover trigger="click" v-model:value="indentLeftPanelVisible">
  231. <template #content>
  232. <PopoverMenuItem center @click="emitRichTextCommand('textIndent', '-1')">减小首行缩进</PopoverMenuItem>
  233. </template>
  234. <Button last class="popover-btn"><IconDown /></Button>
  235. </Popover>
  236. </ButtonGroup>
  237. <div style="width: 10px;"></div>
  238. <ButtonGroup style="flex: 1;" passive>
  239. <Button first style="flex: 1;" v-tooltip="'增大段落缩进'" @click="emitRichTextCommand('indent', '+1')"><IconIndentRight /></Button>
  240. <Popover trigger="click" v-model:value="indentRightPanelVisible">
  241. <template #content>
  242. <PopoverMenuItem center @click="emitRichTextCommand('textIndent', '+1')">增大首行缩进</PopoverMenuItem>
  243. </template>
  244. <Button last class="popover-btn"><IconDown /></Button>
  245. </Popover>
  246. </ButtonGroup>
  247. </div> -->
  248. </div>
  249. </template>
  250. <script lang="ts" setup>
  251. import { ref, watch } from 'vue'
  252. import { storeToRefs } from 'pinia'
  253. import api from '@/services'
  254. import { useMainStore } from '@/store'
  255. import emitter, { EmitterEvents } from '@/utils/emitter'
  256. import { FONTS } from '@/configs/font'
  257. import useTextFormatPainter from '@/hooks/useTextFormatPainter'
  258. import message from '@/utils/message'
  259. import { htmlToText } from '@/utils/common'
  260. import TextColorButton from '@/components/TextColorButton2.vue'
  261. import CheckboxButton from '@/components/CheckboxButton.vue'
  262. import ColorPicker from '@/components/ColorPicker/index.vue'
  263. import Input from '@/components/Input.vue'
  264. import Button from '@/components/Button.vue'
  265. import ButtonGroup from '@/components/ButtonGroup.vue'
  266. import Select from '@/components/Select.vue'
  267. import SelectGroup from '@/components/SelectGroup.vue'
  268. import Divider from '@/components/Divider.vue'
  269. import Popover from '@/components/Popover.vue'
  270. import RadioButton from '@/components/RadioButton.vue'
  271. import RadioGroup from '@/components/RadioGroup.vue'
  272. import PopoverMenuItem from '@/components/PopoverMenuItem.vue'
  273. import { lang } from '@/main'
  274. const { handleElement, handleElementId, richTextAttrs, textFormatPainter } = storeToRefs(useMainStore())
  275. const { toggleTextFormatPainter } = useTextFormatPainter()
  276. const fontSizeOptions = [
  277. '12px', '14px', '16px', '18px', '20px', '22px', '24px', '28px', '32px',
  278. '36px', '40px', '44px', '48px', '54px', '60px', '66px', '72px', '76px',
  279. '80px', '88px', '96px', '104px', '112px', '120px',
  280. ]
  281. const emitRichTextCommand = (command: string, value?: string) => {
  282. emitter.emit(EmitterEvents.RICH_TEXT_COMMAND, { action: { command, value } })
  283. }
  284. const bulletListPanelVisible = ref(false)
  285. const orderedListPanelVisible = ref(false)
  286. const indentLeftPanelVisible = ref(false)
  287. const indentRightPanelVisible = ref(false)
  288. const bulletListStyleTypeOption = ref(['disc', 'circle', 'square'])
  289. const orderedListStyleTypeOption = ref(['decimal', 'lower-roman', 'upper-roman', 'lower-alpha', 'upper-alpha', 'lower-greek'])
  290. const link = ref('')
  291. const linkPopoverVisible = ref(false)
  292. const AIPopoverVisible = ref(false)
  293. const isAIWriting = ref(false)
  294. watch(richTextAttrs, () => linkPopoverVisible.value = false)
  295. watch(handleElementId, () => {
  296. if (isAIWriting.value) isAIWriting.value = false
  297. })
  298. const openLinkPopover = () => {
  299. link.value = richTextAttrs.value.link
  300. }
  301. const updateLink = (link?: string) => {
  302. const linkRegExp = /^(https?):\/\/[\w\-]+(\.[\w\-]+)+([\w\-.,@?^=%&:\/~+#]*[\w\-@?^=%&\/~+#])?$/
  303. if (!link || !linkRegExp.test(link)) return message.error('不是正确的网页链接地址')
  304. emitRichTextCommand('link', link)
  305. linkPopoverVisible.value = false
  306. }
  307. const removeLink = () => {
  308. emitRichTextCommand('link')
  309. linkPopoverVisible.value = false
  310. }
  311. const execAI = async (command: string) => {
  312. AIPopoverVisible.value = false
  313. if (!handleElement.value) return
  314. let content = ''
  315. if (handleElement.value.type === 'text' && handleElement.value.content) {
  316. content = handleElement.value.content
  317. }
  318. if (handleElement.value.type === 'shape' && handleElement.value.text && handleElement.value.text.content) {
  319. content = handleElement.value.text.content
  320. }
  321. if (!content) return message.error('没有可以执行的文本内容')
  322. let resultText = ''
  323. const stream = await api.AI_Writing({
  324. content: htmlToText(content),
  325. command,
  326. })
  327. isAIWriting.value = true
  328. const reader: ReadableStreamDefaultReader = stream.body.getReader()
  329. const decoder = new TextDecoder('utf-8')
  330. const readStream = () => {
  331. reader.read().then(({ done, value }) => {
  332. if (!isAIWriting.value) return
  333. if (done) {
  334. isAIWriting.value = false
  335. return
  336. }
  337. const chunk = decoder.decode(value, { stream: true })
  338. resultText += chunk
  339. emitRichTextCommand('replace', resultText)
  340. readStream()
  341. })
  342. }
  343. readStream()
  344. }
  345. </script>
  346. <style lang="scss" scoped>
  347. .rich-text-base {
  348. user-select: none;
  349. display: flex;
  350. align-items: center;
  351. gap: 10px;
  352. ::v-deep(.ai-loading) {
  353. width: 16px;
  354. height: 16px;
  355. display: inline-block;
  356. margin-top: 8px;
  357. border: 1px solid $themeColor;
  358. border-top-color: transparent;
  359. border-radius: 50%;
  360. animation: spinner .8s linear infinite;
  361. }
  362. }
  363. .row {
  364. width: 100%;
  365. display: flex;
  366. align-items: center;
  367. // margin-bottom: 10px;
  368. }
  369. .font-size-btn {
  370. padding: 0 15px;
  371. }
  372. .link-popover {
  373. width: 240px;
  374. .btns {
  375. margin-top: 10px;
  376. text-align: right;
  377. }
  378. }
  379. .list-wrap {
  380. width: 176px;
  381. color: #666;
  382. padding: 8px;
  383. margin: -12px;
  384. display: flex;
  385. flex-wrap: wrap;
  386. align-content: flex-start;
  387. }
  388. .list {
  389. background-color: $lightGray;
  390. padding: 4px 4px 4px 20px;
  391. cursor: pointer;
  392. &:not(:nth-child(3n)) {
  393. margin-right: 8px;
  394. }
  395. &:nth-child(4),
  396. &:nth-child(5),
  397. &:nth-child(6) {
  398. margin-top: 8px;
  399. }
  400. &:hover {
  401. color: $themeColor;
  402. span {
  403. background-color: $themeColor;
  404. }
  405. }
  406. }
  407. .list-item {
  408. width: 24px;
  409. height: 12px;
  410. position: relative;
  411. font-size: 12px;
  412. top: -3px;
  413. span {
  414. width: 100%;
  415. height: 2px;
  416. display: inline-block;
  417. position: absolute;
  418. top: 8px;
  419. background-color: #666;
  420. }
  421. }
  422. .popover-btn {
  423. padding: 0 3px;
  424. }
  425. @keyframes spinner {
  426. 0% {
  427. transform: rotate(0deg);
  428. }
  429. 100% {
  430. transform: rotate(360deg);
  431. }
  432. }
  433. </style>