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