CzRger месяцев назад: 3
Родитель
Сommit
6c8a664e15
3 измененных файлов с 403 добавлено и 324 удалено
  1. 56 0
      src/utils/useTextToSpeech.ts
  2. 13 324
      src/views/manage/app/index.vue
  3. 334 0
      src/views/manage/app/index_markdown.vue

+ 56 - 0
src/utils/useTextToSpeech.ts

@@ -0,0 +1,56 @@
+// src/composables/useTextToSpeech.ts
+import { onUnmounted } from 'vue'
+
+export default function useTextToSpeech() {
+  const speech = new SpeechSynthesisUtterance()
+  let isPlaying = false
+
+  // 过滤HTML标签
+  const stripHtml = (html: string): string => {
+    const doc = new DOMParser().parseFromString(html, 'text/html')
+    return doc.body.textContent || ''
+  }
+
+  // 播放文本
+  const speak = (text: string) => {
+    stop() // 停止当前播放
+
+    const cleanText = stripHtml(text).trim()
+    if (!cleanText) {
+      console.warn('No valid text to speak')
+      return
+    }
+
+    speech.text = cleanText
+    isPlaying = true
+
+    speech.onend = () => {
+      isPlaying = false
+    }
+
+    speech.onerror = (event) => {
+      // console.error('Speech error:', event)
+      isPlaying = false
+    }
+
+    window.speechSynthesis.speak(speech)
+  }
+
+  // 停止播放
+  const stop = () => {
+    if (isPlaying) {
+      window.speechSynthesis.cancel()
+      isPlaying = false
+    }
+  }
+
+  // 组件卸载时自动停止
+  onUnmounted(() => {
+    stop()
+  })
+
+  return {
+    speak,
+    stop,
+  }
+}

+ 13 - 324
src/views/manage/app/index.vue

@@ -1,334 +1,23 @@
 <template>
   <div>
-    <el-card>
-      <!--      <CzrRich v-model="" />-->
-    </el-card>
-    <el-card>
-      <div class="w-full h-[600px]">
-        <CzrMarkdown />
-      </div>
-    </el-card>
-    <div class="editor-container">
-      <div
-        ref="editorRef"
-        class="editor-content"
-        contenteditable="true"
-        @input="handleInput"
-        @keydown="handleKeyDown"
-        @click="hideSuggestions"
-      ></div>
-
-      <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>{{ state.text }}</div>
+    <el-button @click="speak(state.text)">播放</el-button>
+    <el-button @click="stop">暂停</el-button>
   </div>
 </template>
 
-<script setup>
-import { ref, computed, onMounted } from 'vue'
-import CzrMarkdown from '@/components/czr-ui/CzrMarkdown/CzrMarkdown.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
-})
+<script setup lang="ts">
+import { getCurrentInstance, reactive, ref } from 'vue'
 
-// 过滤后的联想项
-const filteredItems = computed(() => {
-  if (!searchQuery.value) return currentItems.value
+import useTextToSpeech from '@/utils/useTextToSpeech'
 
-  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>'
+const { speak, stop } = useTextToSpeech()
+const emit = defineEmits([])
+const props = defineProps({})
+const { proxy }: any = getCurrentInstance()
+const state: any = reactive({
+  text: '这个实现提供了完整的朗读控制功能,包括开始、暂停、继续和停止,以及语速、音调和语音选择的自定义选项,并添加了朗读进度显示和当前朗读句子高亮功能。',
 })
 </script>
 
-<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>
+<style lang="scss" scoped></style>

+ 334 - 0
src/views/manage/app/index_markdown.vue

@@ -0,0 +1,334 @@
+<template>
+  <div>
+    <el-card>
+      <!--      <CzrRich v-model="" />-->
+    </el-card>
+    <el-card>
+      <div class="w-full h-[600px]">
+        <CzrMarkdown />
+      </div>
+    </el-card>
+    <div class="editor-container">
+      <div
+        ref="editorRef"
+        class="editor-content"
+        contenteditable="true"
+        @input="handleInput"
+        @keydown="handleKeyDown"
+        @click="hideSuggestions"
+      ></div>
+
+      <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>
+
+<script setup>
+import { ref, computed, onMounted } from 'vue'
+import CzrMarkdown from '@/components/czr-ui/CzrMarkdown/CzrMarkdown.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>
+.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>