index.vue 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. <template>
  2. <div>
  3. <CzrRich />
  4. <div class="editor-container">
  5. <div
  6. ref="editorRef"
  7. class="editor-content"
  8. contenteditable="true"
  9. @input="handleInput"
  10. @keydown="handleKeyDown"
  11. @click="hideSuggestions"
  12. ></div>
  13. <div v-if="showSuggestions" class="suggestion-menu" :style="menuPosition">
  14. <div
  15. v-for="(item, index) in filteredItems"
  16. :key="item.key"
  17. :class="['suggestion-item', { active: index === selectedIndex }]"
  18. @mousedown.prevent="selectItem(item)"
  19. @mouseenter="selectedIndex = index"
  20. >
  21. <span class="suggestion-icon">{{ item.icon }}</span>
  22. <div class="suggestion-text">
  23. <div class="suggestion-title">{{ item.title }}</div>
  24. <div class="suggestion-desc">{{ item.description }}</div>
  25. </div>
  26. </div>
  27. </div>
  28. </div>
  29. </div>
  30. </template>
  31. <script setup>
  32. import { ref, computed, onMounted } from 'vue'
  33. const editorRef = ref(null)
  34. const showSuggestions = ref(false)
  35. const selectedIndex = ref(0)
  36. const triggerPosition = ref({ top: 0, left: 0 })
  37. const searchQuery = ref('')
  38. const triggerChar = ref('')
  39. // 联想数据
  40. const slashCommands = [
  41. {
  42. key: 'heading',
  43. title: '标题',
  44. description: '插入二级标题',
  45. icon: '🔠',
  46. command: () => insertContent('<h2>标题</h2>'),
  47. },
  48. {
  49. key: 'list',
  50. title: '无序列表',
  51. description: '插入项目符号列表',
  52. icon: '•',
  53. command: () => insertContent('<ul><li>列表项</li></ul>'),
  54. },
  55. {
  56. key: 'divider',
  57. title: '分隔线',
  58. description: '插入水平分隔线',
  59. icon: '―',
  60. command: () => insertContent('<hr>'),
  61. },
  62. ]
  63. const mentionUsers = [
  64. {
  65. key: 'user1',
  66. title: '张三',
  67. description: '前端工程师',
  68. icon: '👨‍💻',
  69. command: () => insertContent('@张三 '),
  70. },
  71. {
  72. key: 'user2',
  73. title: '李四',
  74. description: 'UI设计师',
  75. icon: '🎨',
  76. command: () => insertContent('@李四 '),
  77. },
  78. ]
  79. // 当前显示的联想项
  80. const currentItems = computed(() => {
  81. return triggerChar.value === '/' ? slashCommands : mentionUsers
  82. })
  83. // 过滤后的联想项
  84. const filteredItems = computed(() => {
  85. if (!searchQuery.value) return currentItems.value
  86. return currentItems.value.filter(
  87. (item) =>
  88. item.title.includes(searchQuery.value) ||
  89. item.description.includes(searchQuery.value),
  90. )
  91. })
  92. // 插入内容到编辑器
  93. function insertContent(html) {
  94. const selection = window.getSelection()
  95. if (!selection.rangeCount) return
  96. const range = selection.getRangeAt(0)
  97. range.deleteContents()
  98. const div = document.createElement('div')
  99. div.innerHTML = html
  100. const fragment = document.createDocumentFragment()
  101. while (div.firstChild) {
  102. fragment.appendChild(div.firstChild)
  103. }
  104. range.insertNode(fragment)
  105. setCursorAfterInsertion(range)
  106. hideSuggestions()
  107. }
  108. // 设置插入后的光标位置
  109. function setCursorAfterInsertion(range) {
  110. const selection = window.getSelection()
  111. selection.removeAllRanges()
  112. const newRange = document.createRange()
  113. newRange.setStartAfter(range.endContainer.lastChild || range.endContainer)
  114. newRange.collapse(true)
  115. selection.addRange(newRange)
  116. editorRef.value.focus()
  117. }
  118. // 处理输入事件
  119. function handleInput(e) {
  120. const selection = window.getSelection()
  121. if (!selection.rangeCount) return
  122. const range = selection.getRangeAt(0)
  123. const textBeforeCursor = getTextBeforeCursor(range)
  124. // 检查是否输入了触发字符
  125. const lastSlashIndex = textBeforeCursor.lastIndexOf('/')
  126. const lastAtIndex = textBeforeCursor.lastIndexOf('@')
  127. if (
  128. lastSlashIndex > -1 &&
  129. (lastSlashIndex === 0 || textBeforeCursor[lastSlashIndex - 1] === ' ')
  130. ) {
  131. showSuggestionMenu('/', lastSlashIndex, range)
  132. } else if (
  133. lastAtIndex > -1 &&
  134. (lastAtIndex === 0 || textBeforeCursor[lastAtIndex - 1] === ' ')
  135. ) {
  136. showSuggestionMenu('@', lastAtIndex, range)
  137. } else {
  138. hideSuggestions()
  139. }
  140. }
  141. // 显示联想菜单
  142. function showSuggestionMenu(char, charIndex, range) {
  143. triggerChar.value = char
  144. searchQuery.value = getTextBeforeCursor(range).slice(charIndex + 1)
  145. // 获取触发字符的位置
  146. const rangeBefore = document.createRange()
  147. rangeBefore.setStart(range.startContainer, 0)
  148. rangeBefore.setEnd(range.startContainer, charIndex + 1)
  149. const rect = rangeBefore.getBoundingClientRect()
  150. triggerPosition.value = {
  151. top: rect.bottom + window.scrollY,
  152. left: rect.left + window.scrollX,
  153. }
  154. showSuggestions.value = true
  155. selectedIndex.value = 0
  156. }
  157. // 获取光标前的文本
  158. function getTextBeforeCursor(range) {
  159. const container = range.startContainer
  160. const offset = range.startOffset
  161. if (container.nodeType === Node.TEXT_NODE) {
  162. return container.textContent.slice(0, offset)
  163. } else if (container.nodeType === Node.ELEMENT_NODE) {
  164. let text = ''
  165. const walker = document.createTreeWalker(
  166. container,
  167. NodeFilter.SHOW_TEXT,
  168. null,
  169. false,
  170. )
  171. let node
  172. while ((node = walker.nextNode())) {
  173. if (node === range.startContainer) {
  174. text += node.textContent.slice(0, range.startOffset)
  175. break
  176. }
  177. text += node.textContent
  178. }
  179. return text
  180. }
  181. return ''
  182. }
  183. // 处理键盘事件
  184. function handleKeyDown(e) {
  185. if (!showSuggestions.value) return
  186. switch (e.key) {
  187. case 'ArrowUp':
  188. e.preventDefault()
  189. selectedIndex.value = Math.max(0, selectedIndex.value - 1)
  190. break
  191. case 'ArrowDown':
  192. e.preventDefault()
  193. selectedIndex.value = Math.min(
  194. filteredItems.value.length - 1,
  195. selectedIndex.value + 1,
  196. )
  197. break
  198. case 'Enter':
  199. e.preventDefault()
  200. if (filteredItems.value[selectedIndex.value]) {
  201. selectItem(filteredItems.value[selectedIndex.value])
  202. }
  203. break
  204. case 'Escape':
  205. e.preventDefault()
  206. hideSuggestions()
  207. break
  208. }
  209. }
  210. // 选择联想项
  211. function selectItem(item) {
  212. if (item && item.command) {
  213. item.command()
  214. }
  215. }
  216. // 隐藏联想菜单
  217. function hideSuggestions() {
  218. showSuggestions.value = false
  219. searchQuery.value = ''
  220. triggerChar.value = ''
  221. }
  222. // 菜单位置
  223. const menuPosition = computed(() => ({
  224. top: `${triggerPosition.value.top}px`,
  225. left: `${triggerPosition.value.left}px`,
  226. }))
  227. onMounted(() => {
  228. // 初始内容
  229. editorRef.value.innerHTML = '<p>输入 / 或 @ 触发联想菜单</p>'
  230. })
  231. </script>
  232. <style>
  233. .editor-container {
  234. position: relative;
  235. max-width: 800px;
  236. margin: 0 auto;
  237. }
  238. .editor-content {
  239. border: 1px solid #ddd;
  240. border-radius: 4px;
  241. padding: 12px;
  242. min-height: 200px;
  243. outline: none;
  244. }
  245. .editor-content:focus {
  246. border-color: #646cff;
  247. }
  248. .suggestion-menu {
  249. position: absolute;
  250. background: white;
  251. border: 1px solid #ddd;
  252. border-radius: 4px;
  253. box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
  254. z-index: 100;
  255. width: 280px;
  256. max-height: 300px;
  257. overflow-y: auto;
  258. }
  259. .suggestion-item {
  260. display: flex;
  261. align-items: center;
  262. padding: 8px 12px;
  263. cursor: pointer;
  264. }
  265. .suggestion-item:hover,
  266. .suggestion-item.active {
  267. background-color: #f5f5f5;
  268. }
  269. .suggestion-icon {
  270. margin-right: 10px;
  271. font-size: 18px;
  272. }
  273. .suggestion-text {
  274. flex: 1;
  275. }
  276. .suggestion-title {
  277. font-weight: 500;
  278. }
  279. .suggestion-desc {
  280. font-size: 12px;
  281. color: #666;
  282. }
  283. </style>