RichTextEditor.vue 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. <script setup lang="ts">
  2. import { ref, watch, onMounted, nextTick } from 'vue';
  3. const props = defineProps<{
  4. modelValue?: string;
  5. }>();
  6. const emits = defineEmits<{
  7. 'update:modelValue': [value: string];
  8. change: [value: string];
  9. }>();
  10. const editorRef = ref<HTMLDivElement>();
  11. const content = ref(props.modelValue || '');
  12. // 监听外部值变化
  13. watch(
  14. () => props.modelValue,
  15. (newVal) => {
  16. if (newVal !== content.value && editorRef.value) {
  17. editorRef.value.innerHTML = newVal || '';
  18. content.value = newVal || '';
  19. }
  20. }
  21. );
  22. // 内容变化处理
  23. const handleInput = () => {
  24. if (editorRef.value) {
  25. const html = editorRef.value.innerHTML;
  26. content.value = html;
  27. emits('update:modelValue', html);
  28. emits('change', html);
  29. }
  30. };
  31. // 工具栏命令
  32. const execCommand = (command: string, value?: string | boolean) => {
  33. document.execCommand(command, false, value as string);
  34. editorRef.value?.focus();
  35. handleInput();
  36. };
  37. // 格式化按钮
  38. const formatBold = () => execCommand('bold');
  39. const formatItalic = () => execCommand('italic');
  40. const formatUnderline = () => execCommand('underline');
  41. // 对齐
  42. const alignLeft = () => execCommand('justifyLeft');
  43. const alignCenter = () => execCommand('justifyCenter');
  44. const alignRight = () => execCommand('justifyRight');
  45. const alignJustify = () => execCommand('justifyFull');
  46. // 列表
  47. const insertUnorderedList = () => execCommand('insertUnorderedList');
  48. const insertOrderedList = () => execCommand('insertOrderedList');
  49. // 其他
  50. const formatBlockquote = () => execCommand('formatBlock', 'blockquote');
  51. const indent = () => execCommand('indent');
  52. const outdent = () => execCommand('outdent');
  53. const insertLink = () => {
  54. const url = prompt('请输入链接地址:');
  55. if (url) {
  56. execCommand('createLink', url);
  57. }
  58. };
  59. const insertCode = () => execCommand('formatBlock', 'pre');
  60. // 撤销/重做
  61. const undo = () => execCommand('undo');
  62. const redo = () => execCommand('redo');
  63. // 插入图片
  64. const insertImage = () => {
  65. const url = prompt('请输入图片地址:');
  66. if (url) {
  67. execCommand('insertImage', url);
  68. }
  69. };
  70. // 插入视频
  71. const insertVideo = () => {
  72. const url = prompt('请输入视频地址:');
  73. if (url && editorRef.value) {
  74. const video = document.createElement('video');
  75. video.controls = true;
  76. video.src = url;
  77. video.style.maxWidth = '100%';
  78. editorRef.value.appendChild(video);
  79. handleInput();
  80. }
  81. };
  82. // 段落格式
  83. const formatParagraph = (tag: string) => {
  84. execCommand('formatBlock', tag);
  85. };
  86. onMounted(() => {
  87. if (editorRef.value && props.modelValue) {
  88. editorRef.value.innerHTML = props.modelValue;
  89. }
  90. });
  91. </script>
  92. <template>
  93. <div class="rich-text-editor">
  94. <!-- 工具栏第一行 -->
  95. <div class="toolbar toolbar-row-1">
  96. <a-select
  97. :value="'p'"
  98. style="width: 100px"
  99. @change="(val) => formatParagraph(val)"
  100. placeholder="段落"
  101. >
  102. <a-select-option value="p">段落</a-select-option>
  103. <a-select-option value="h1">标题1</a-select-option>
  104. <a-select-option value="h2">标题2</a-select-option>
  105. <a-select-option value="h3">标题3</a-select-option>
  106. </a-select>
  107. <div class="toolbar-divider"></div>
  108. <a-button-group>
  109. <a-button @click="formatBold" title="粗体" size="small">
  110. <strong style="font-weight: bold">B</strong>
  111. </a-button>
  112. <a-button @click="formatItalic" title="斜体" size="small">
  113. <em style="font-style: italic">I</em>
  114. </a-button>
  115. <a-button @click="formatUnderline" title="下划线" size="small">
  116. <u style="text-decoration: underline">U</u>
  117. </a-button>
  118. </a-button-group>
  119. <div class="toolbar-divider"></div>
  120. <a-button-group>
  121. <a-button @click="alignLeft" title="左对齐" size="small">⬅</a-button>
  122. <a-button @click="alignCenter" title="居中" size="small">⬍</a-button>
  123. <a-button @click="alignRight" title="右对齐" size="small">➡</a-button>
  124. <a-button @click="alignJustify" title="两端对齐" size="small">⬌</a-button>
  125. </a-button-group>
  126. <div class="toolbar-divider"></div>
  127. <a-button-group>
  128. <a-button @click="insertUnorderedList" title="无序列表" size="small">•</a-button>
  129. <a-button @click="insertOrderedList" title="有序列表" size="small">1.</a-button>
  130. </a-button-group>
  131. <div class="toolbar-divider"></div>
  132. <a-button @click="formatBlockquote" title="引用" size="small">"</a-button>
  133. <a-button @click="outdent" title="减少缩进" size="small">◀</a-button>
  134. <a-button @click="indent" title="增加缩进" size="small">▶</a-button>
  135. <a-button @click="insertLink" title="链接" size="small">🔗</a-button>
  136. <a-button @click="formatBlockquote" title="引用块" size="small">[]</a-button>
  137. <a-button @click="insertCode" title="代码块" size="small">{}</a-button>
  138. </div>
  139. <!-- 工具栏第二行 -->
  140. <div class="toolbar toolbar-row-2">
  141. <a-button @click="undo" title="撤销" size="small">↶</a-button>
  142. <a-button @click="redo" title="重做" size="small">↷</a-button>
  143. <a-button @click="insertImage" title="图片" size="small">🖼️</a-button>
  144. <a-button @click="insertVideo" title="视频" size="small">📹</a-button>
  145. <a-button title="计数" size="small" class="counter-btn">1</a-button>
  146. </div>
  147. <!-- 编辑区域 -->
  148. <div
  149. ref="editorRef"
  150. class="editor-content"
  151. contenteditable="true"
  152. @input="handleInput"
  153. @paste="handleInput"
  154. placeholder="请输入文字"
  155. ></div>
  156. </div>
  157. </template>
  158. <style scoped lang="scss">
  159. .rich-text-editor {
  160. border: 1px solid #d9d9d9;
  161. border-radius: 4px;
  162. background: #fff;
  163. display: flex;
  164. flex-direction: column;
  165. }
  166. .toolbar {
  167. display: flex;
  168. align-items: center;
  169. gap: 8px;
  170. padding: 8px 12px;
  171. border-bottom: 1px solid #d9d9d9;
  172. background: #fafafa;
  173. flex-wrap: wrap;
  174. }
  175. .toolbar-row-1 {
  176. border-bottom: 1px solid #e8e8e8;
  177. }
  178. .toolbar-row-2 {
  179. border-top: 1px solid #e8e8e8;
  180. }
  181. .toolbar-divider {
  182. width: 1px;
  183. height: 20px;
  184. background: #d9d9d9;
  185. margin: 0 4px;
  186. }
  187. .editor-content {
  188. min-height: 300px;
  189. padding: 12px;
  190. outline: none;
  191. overflow-y: auto;
  192. line-height: 1.6;
  193. color: #000;
  194. &:empty::before {
  195. content: attr(placeholder);
  196. color: #bfbfbf;
  197. }
  198. :deep(img) {
  199. max-width: 100%;
  200. height: auto;
  201. }
  202. :deep(video) {
  203. max-width: 100%;
  204. height: auto;
  205. }
  206. }
  207. :deep(.ant-btn) {
  208. border-color: #d9d9d9;
  209. color: #000;
  210. height: 28px;
  211. padding: 0 8px;
  212. &:hover {
  213. border-color: #40a9ff;
  214. color: #40a9ff;
  215. }
  216. }
  217. .counter-btn {
  218. background: #ffd700 !important;
  219. border-color: #ffd700 !important;
  220. color: #000 !important;
  221. }
  222. </style>