index.vue 7.4 KB

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