|
@@ -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>
|