Browse Source

富文本

CzRger 3 months ago
parent
commit
681830f2af

+ 64 - 0
src/components/czr-ui/CzrRich/CzrRich.vue

@@ -0,0 +1,64 @@
+<template>
+  <div class="czr-rich">
+    <div
+      ref="ref_rich"
+      class="czr-rich-content"
+      contenteditable="true"
+      @input="handleInput"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+defineOptions({
+  name: 'CzrRich',
+})
+import { getCurrentInstance, onMounted, reactive, ref } from 'vue'
+
+const emit = defineEmits([])
+const props = defineProps({})
+const { proxy }: any = getCurrentInstance()
+const state: any = reactive({})
+const ref_rich = ref()
+const handleInput = (e) => {
+  const selection = window.getSelection()
+  if (!selection.rangeCount) return
+
+  // const range = selection.getRangeAt(0)
+  // const textBeforeCursor = getTextBeforeCursor(range)
+
+  // // 检查是否输入了触发字符
+  // const lastSlashIndex = textBeforeCursor.lastIndexOf('/')
+  // const lastAtIndex = textBeforeCursor.lastIndexOf('@')
+  //
+  // if (
+  //   lastSlashIndex > -1 &&
+  //   (lastSlashIndex === 0 || textBeforeCursor[lastSlashIndex - 1] === ' ')
+  // ) {
+  //   showSuggestionMenu('/', lastSlashIndex, range)
+  // } else if (
+  //   lastAtIndex > -1 &&
+  //   (lastAtIndex === 0 || textBeforeCursor[lastAtIndex - 1] === ' ')
+  // ) {
+  //   showSuggestionMenu('@', lastAtIndex, range)
+  // } else {
+  //   hideSuggestions()
+  // }
+}
+onMounted(() => {
+  ref_rich.value.innerHTML = '<p></p>'
+})
+</script>
+
+<style lang="scss" scoped>
+.czr-rich {
+  width: 600px;
+  height: 400px;
+  border: 1px solid #000000;
+  padding: 10px;
+  display: flex;
+  .czr-rich-content {
+    flex: 1;
+  }
+}
+</style>

+ 1 - 1
src/plugins/initComponent.ts

@@ -5,7 +5,7 @@ interface FileType {
 
 // @ts-ignore
 const Components: Record<string, FileType> = import.meta.glob(
-  '/src/components/*/*.vue',
+  '/src/components/**/*.vue',
   { eager: true },
 )
 

+ 322 - 8
src/views/manage/app/index.vue

@@ -1,12 +1,326 @@
-<template>应用中心</template>
+<template>
+  <div>
+    <CzrRich />
+    <div class="editor-container">
+      <div
+        ref="editorRef"
+        class="editor-content"
+        contenteditable="true"
+        @input="handleInput"
+        @keydown="handleKeyDown"
+        @click="hideSuggestions"
+      ></div>
 
-<script setup lang="ts">
-import { getCurrentInstance, reactive, ref } from 'vue'
+      <div v-if="showSuggestions" class="suggestion-menu" :style="menuPosition">
+        <div
+          v-for="(item, index) in filteredItems"
+          :key="item.key"
+          :class="['suggestion-item', { active: index === selectedIndex }]"
+          @mousedown.prevent="selectItem(item)"
+          @mouseenter="selectedIndex = index"
+        >
+          <span class="suggestion-icon">{{ item.icon }}</span>
+          <div class="suggestion-text">
+            <div class="suggestion-title">{{ item.title }}</div>
+            <div class="suggestion-desc">{{ item.description }}</div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
 
-const emit = defineEmits([])
-const props = defineProps({})
-const { proxy }: any = getCurrentInstance()
-const state: any = reactive({})
+<script setup>
+import { ref, computed, onMounted } from 'vue'
+
+const editorRef = ref(null)
+const showSuggestions = ref(false)
+const selectedIndex = ref(0)
+const triggerPosition = ref({ top: 0, left: 0 })
+const searchQuery = ref('')
+const triggerChar = ref('')
+
+// 联想数据
+const slashCommands = [
+  {
+    key: 'heading',
+    title: '标题',
+    description: '插入二级标题',
+    icon: '🔠',
+    command: () => insertContent('<h2>标题</h2>'),
+  },
+  {
+    key: 'list',
+    title: '无序列表',
+    description: '插入项目符号列表',
+    icon: '•',
+    command: () => insertContent('<ul><li>列表项</li></ul>'),
+  },
+  {
+    key: 'divider',
+    title: '分隔线',
+    description: '插入水平分隔线',
+    icon: '―',
+    command: () => insertContent('<hr>'),
+  },
+]
+
+const mentionUsers = [
+  {
+    key: 'user1',
+    title: '张三',
+    description: '前端工程师',
+    icon: '👨‍💻',
+    command: () => insertContent('@张三 '),
+  },
+  {
+    key: 'user2',
+    title: '李四',
+    description: 'UI设计师',
+    icon: '🎨',
+    command: () => insertContent('@李四 '),
+  },
+]
+
+// 当前显示的联想项
+const currentItems = computed(() => {
+  return triggerChar.value === '/' ? slashCommands : mentionUsers
+})
+
+// 过滤后的联想项
+const filteredItems = computed(() => {
+  if (!searchQuery.value) return currentItems.value
+
+  return currentItems.value.filter(
+    (item) =>
+      item.title.includes(searchQuery.value) ||
+      item.description.includes(searchQuery.value),
+  )
+})
+
+// 插入内容到编辑器
+function insertContent(html) {
+  const selection = window.getSelection()
+  if (!selection.rangeCount) return
+
+  const range = selection.getRangeAt(0)
+  range.deleteContents()
+
+  const div = document.createElement('div')
+  div.innerHTML = html
+  const fragment = document.createDocumentFragment()
+
+  while (div.firstChild) {
+    fragment.appendChild(div.firstChild)
+  }
+
+  range.insertNode(fragment)
+  setCursorAfterInsertion(range)
+  hideSuggestions()
+}
+
+// 设置插入后的光标位置
+function setCursorAfterInsertion(range) {
+  const selection = window.getSelection()
+  selection.removeAllRanges()
+
+  const newRange = document.createRange()
+  newRange.setStartAfter(range.endContainer.lastChild || range.endContainer)
+  newRange.collapse(true)
+
+  selection.addRange(newRange)
+  editorRef.value.focus()
+}
+
+// 处理输入事件
+function handleInput(e) {
+  const selection = window.getSelection()
+  if (!selection.rangeCount) return
+
+  const range = selection.getRangeAt(0)
+  const textBeforeCursor = getTextBeforeCursor(range)
+
+  // 检查是否输入了触发字符
+  const lastSlashIndex = textBeforeCursor.lastIndexOf('/')
+  const lastAtIndex = textBeforeCursor.lastIndexOf('@')
+
+  if (
+    lastSlashIndex > -1 &&
+    (lastSlashIndex === 0 || textBeforeCursor[lastSlashIndex - 1] === ' ')
+  ) {
+    showSuggestionMenu('/', lastSlashIndex, range)
+  } else if (
+    lastAtIndex > -1 &&
+    (lastAtIndex === 0 || textBeforeCursor[lastAtIndex - 1] === ' ')
+  ) {
+    showSuggestionMenu('@', lastAtIndex, range)
+  } else {
+    hideSuggestions()
+  }
+}
+
+// 显示联想菜单
+function showSuggestionMenu(char, charIndex, range) {
+  triggerChar.value = char
+  searchQuery.value = getTextBeforeCursor(range).slice(charIndex + 1)
+
+  // 获取触发字符的位置
+  const rangeBefore = document.createRange()
+  rangeBefore.setStart(range.startContainer, 0)
+  rangeBefore.setEnd(range.startContainer, charIndex + 1)
+
+  const rect = rangeBefore.getBoundingClientRect()
+  triggerPosition.value = {
+    top: rect.bottom + window.scrollY,
+    left: rect.left + window.scrollX,
+  }
+
+  showSuggestions.value = true
+  selectedIndex.value = 0
+}
+
+// 获取光标前的文本
+function getTextBeforeCursor(range) {
+  const container = range.startContainer
+  const offset = range.startOffset
+
+  if (container.nodeType === Node.TEXT_NODE) {
+    return container.textContent.slice(0, offset)
+  } else if (container.nodeType === Node.ELEMENT_NODE) {
+    let text = ''
+    const walker = document.createTreeWalker(
+      container,
+      NodeFilter.SHOW_TEXT,
+      null,
+      false,
+    )
+
+    let node
+    while ((node = walker.nextNode())) {
+      if (node === range.startContainer) {
+        text += node.textContent.slice(0, range.startOffset)
+        break
+      }
+      text += node.textContent
+    }
+    return text
+  }
+  return ''
+}
+
+// 处理键盘事件
+function handleKeyDown(e) {
+  if (!showSuggestions.value) return
+
+  switch (e.key) {
+    case 'ArrowUp':
+      e.preventDefault()
+      selectedIndex.value = Math.max(0, selectedIndex.value - 1)
+      break
+    case 'ArrowDown':
+      e.preventDefault()
+      selectedIndex.value = Math.min(
+        filteredItems.value.length - 1,
+        selectedIndex.value + 1,
+      )
+      break
+    case 'Enter':
+      e.preventDefault()
+      if (filteredItems.value[selectedIndex.value]) {
+        selectItem(filteredItems.value[selectedIndex.value])
+      }
+      break
+    case 'Escape':
+      e.preventDefault()
+      hideSuggestions()
+      break
+  }
+}
+
+// 选择联想项
+function selectItem(item) {
+  if (item && item.command) {
+    item.command()
+  }
+}
+
+// 隐藏联想菜单
+function hideSuggestions() {
+  showSuggestions.value = false
+  searchQuery.value = ''
+  triggerChar.value = ''
+}
+
+// 菜单位置
+const menuPosition = computed(() => ({
+  top: `${triggerPosition.value.top}px`,
+  left: `${triggerPosition.value.left}px`,
+}))
+
+onMounted(() => {
+  // 初始内容
+  editorRef.value.innerHTML = '<p>输入 / 或 @ 触发联想菜单</p>'
+})
 </script>
 
-<style lang="scss" scoped></style>
+<style>
+.editor-container {
+  position: relative;
+  max-width: 800px;
+  margin: 0 auto;
+}
+
+.editor-content {
+  border: 1px solid #ddd;
+  border-radius: 4px;
+  padding: 12px;
+  min-height: 200px;
+  outline: none;
+}
+
+.editor-content:focus {
+  border-color: #646cff;
+}
+
+.suggestion-menu {
+  position: absolute;
+  background: white;
+  border: 1px solid #ddd;
+  border-radius: 4px;
+  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
+  z-index: 100;
+  width: 280px;
+  max-height: 300px;
+  overflow-y: auto;
+}
+
+.suggestion-item {
+  display: flex;
+  align-items: center;
+  padding: 8px 12px;
+  cursor: pointer;
+}
+
+.suggestion-item:hover,
+.suggestion-item.active {
+  background-color: #f5f5f5;
+}
+
+.suggestion-icon {
+  margin-right: 10px;
+  font-size: 18px;
+}
+
+.suggestion-text {
+  flex: 1;
+}
+
+.suggestion-title {
+  font-weight: 500;
+}
+
+.suggestion-desc {
+  font-size: 12px;
+  color: #666;
+}
+</style>