Commit e2326b0f by pangchong

feat(editor): 新增文本选择悬浮工具栏及智能翻译加载提示

- 在 DocNodeRenderer 组件中添加智能翻译加载遮罩显示
- 在 EditorPanel 中集成 SelectionToolbar 组件,展示文本选择悬浮工具栏
- SelectionToolbar 支持节点属性编辑、规则查看、智能翻译等多种操作按钮
- 实现文本选区监测,动态显示和定位悬浮工具栏
- 智能翻译功能可对选中节点内容进行英译中自动翻译
- 处理鼠标键盘事件确保工具栏交互体验流畅
- 优化翻译相关逻辑,支持嵌套混合内容的递归翻译操作
- 添加相应的样式和动画效果提升视觉体验
parent 016289ee
......@@ -13,6 +13,19 @@
:style="!isDiffMode && node.attributes?.MERGED === 'TRUE' ? { backgroundColor: 'yellow' } : {}"
@click.stop="editorStore.setSelectedNodeId(node.id)"
>
<!-- 智能翻译加载遮罩 -->
<Transition name="fade">
<div
v-if="translatingNodeId === node.id"
class="absolute inset-0 z-10 flex items-center justify-center bg-card bg-opacity-75 backdrop-blur-[1px] select-none pointer-events-none rounded"
>
<div class="flex items-center space-x-1.5 bg-popover border border-divider shadow-md rounded-full px-3 py-1 text-primary text-xs font-medium">
<n-icon class="animate-spin"><SyncOutline /></n-icon>
<span>翻译中...</span>
</div>
</div>
</Transition>
<!-- 0. 各类 HEADER 节点特殊处理 (不展示) -->
<template v-if="isHeaderTag"></template>
......@@ -1285,10 +1298,11 @@
<script setup lang="ts">
import DocNodeRenderer from './index.vue'
import { ImageOutline, GridOutline } from '@vicons/ionicons5'
import { ImageOutline, GridOutline, SyncOutline } from '@vicons/ionicons5'
import type { XmlNode } from '@/types/xmlNode'
import TableEditor from '../TableEditor/index.vue'
import { useDocNodeRenderer, getSplitListChildren, getCepTaskNumber, isAllEinDataSameEffect, getDiffWordClass } from './functionals'
import { translatingNodeId } from '@/views/editor/components/NodeTree/functionals'
const getImgSrc = (gnbr: string) => {
if (!gnbr) return ''
......
/**
* SelectionToolbar 专属常量与类型定义
*/
export interface SelectionToolbarProps {}
import { useEditorStore } from '@/store/editor'
import type { XmlNode } from '@/types/xmlNode'
import {
addNodeVisible,
addNodeMode,
addNodeTargetId,
addNodeAllowedTags,
checkRuleVisible,
checkRuleData,
translatingNodeId
} from '@/views/editor/components/NodeTree/functionals'
// ── 翻译相关的通用辅助函数 (逻辑与树节点及右键上下文菜单完全一致) ──
const getEnglishSourceNode = (node: XmlNode, parent: XmlNode | null): XmlNode | null => {
if (!parent || !node.tagName.endsWith('C')) return null
const enTag = node.tagName.slice(0, -1)
if (!getElementRule(enTag)) return null
const idx = parent.children.findIndex((c) => c.id === node.id)
if (idx === -1) return null
for (let i = idx + 1; i < parent.children.length; i++) {
const sibling = parent.children[i]
if (sibling.tagName === node.tagName) break
if (sibling.tagName === enTag) {
return sibling
}
}
return null
}
const hasTranslatableText = (n: XmlNode): boolean => {
if ((n.textContent || '').trim()) return true
return n.children.some(hasTranslatableText)
}
export function useSelectionToolbar() {
const store = useEditorStore()
const visible = ref(false)
const top = ref(0)
const left = ref(0)
const selectedText = ref('')
const activeNodeId = ref<string | null>(null)
const activeNode = ref<XmlNode | null>(null)
const parentNode = ref<XmlNode | null>(null)
const hasActiveNode = computed(() => !!activeNode.value)
const isVirtualText = computed(() => !!activeNodeId.value && activeNodeId.value.includes('-txt-'))
const hasParent = computed(() => {
if (isVirtualText.value) return true
return !!parentNode.value
})
const hasAllowedChildren = computed(() => {
if (!activeNode.value) return false
const allowed = getAllowedChildren(activeNode.value.tagName)
return allowed.length > 0
})
const canDeleteCurrent = computed(() => {
if (!activeNode.value) return false
if (isVirtualText.value) return true
if (!parentNode.value) return false
const existingTags = parentNode.value.children.map((c) => c.tagName)
return canDeleteChild(parentNode.value.tagName, activeNode.value.tagName, existingTags)
})
// 智能翻译是否可用(判定规则与树节点完全一致:必须是真实节点、拥有对应的英文原文节点、且原文有内容)
const canTranslateActive = computed(() => {
if (!activeNode.value || isVirtualText.value) return false
let realId = activeNodeId.value!
if (realId.includes('-txt-')) {
realId = realId.split('-txt-')[0]
}
const mapped = store.nodeMap.get(realId)
if (!mapped) return false
const enSourceNode = getEnglishSourceNode(mapped.node, mapped.parent)
return !!enSourceNode && hasTranslatableText(enSourceNode)
})
const isTranslating = computed(() => {
return translatingNodeId.value === activeNodeId.value
})
const translateNodePairs = async (sourceNode: XmlNode, targetNode: XmlNode): Promise<boolean> => {
if (sourceNode.mixedContent && sourceNode.mixedContent.length > 0 && isMixedContentElement(sourceNode.tagName)) {
const targetByTag: Record<string, XmlNode[]> = {}
for (const child of targetNode.children) {
if (!targetByTag[child.tagName]) targetByTag[child.tagName] = []
targetByTag[child.tagName].push(child)
}
const tagUsedCount: Record<string, number> = {}
const newMixedContent: any[] = []
let anySuccess = false
for (const item of sourceNode.mixedContent) {
if (item.type === 'text') {
const rawText = (item.text || '').trim()
if (rawText) {
const res = (await service.postJson('/translate', {
text: rawText,
search_direction: 'en_to_zh'
})) as any
if (res?.success && res?.translation) {
newMixedContent.push({ type: 'text', text: res.translation })
anySuccess = true
} else {
newMixedContent.push(item)
}
} else {
newMixedContent.push(item)
}
} else if (item.type === 'element' && item.nodeId) {
const srcChild = sourceNode.children.find((c) => c.id === item.nodeId)
if (srcChild) {
const tag = srcChild.tagName
const usedIdx = tagUsedCount[tag] || 0
tagUsedCount[tag] = usedIdx + 1
const tgtChild = (targetByTag[tag] || [])[usedIdx]
if (tgtChild) {
const ok = await translateNodePairs(srcChild, tgtChild)
if (ok) anySuccess = true
newMixedContent.push({ type: 'element', nodeId: tgtChild.id })
} else {
newMixedContent.push(item)
}
} else {
newMixedContent.push(item)
}
}
}
if (anySuccess) {
targetNode.mixedContent = newMixedContent
targetNode.textContent = newMixedContent
.filter((i) => i.type === 'text')
.map((i) => i.text || '')
.join('')
}
return anySuccess
}
const sourceText = (sourceNode.textContent || '').trim()
if (sourceText && sourceNode.children.length === 0) {
const res = (await service.postJson('/translate', {
text: sourceText,
search_direction: 'en_to_zh'
})) as any
if (res?.success && res?.translation) {
targetNode.textContent = res.translation
if (isMixedContentElement(targetNode.tagName)) {
targetNode.mixedContent = [{ type: 'text', text: res.translation }]
}
return true
}
return false
}
if (sourceNode.children.length > 0) {
const sourceByTag: Record<string, XmlNode[]> = {}
for (const child of sourceNode.children) {
if (!sourceByTag[child.tagName]) sourceByTag[child.tagName] = []
sourceByTag[child.tagName].push(child)
}
const targetByTag: Record<string, XmlNode[]> = {}
for (const child of targetNode.children) {
if (!targetByTag[child.tagName]) targetByTag[child.tagName] = []
targetByTag[child.tagName].push(child)
}
let anySuccess = false
for (const [, srcChildren] of Object.entries(sourceByTag)) {
const tgtChildren = targetByTag[srcChildren[0].tagName] || []
for (let i = 0; i < srcChildren.length; i++) {
const tgtChild = tgtChildren[i]
if (tgtChild) {
const ok = await translateNodePairs(srcChildren[i], tgtChild)
if (ok) anySuccess = true
}
}
}
return anySuccess
}
return false
}
const handleTranslateNode = async () => {
if (!activeNode.value) return
let realId = activeNodeId.value!
if (realId.includes('-txt-')) {
realId = realId.split('-txt-')[0]
}
const mapped = store.nodeMap.get(realId)
if (!mapped) return
const enSourceNode = getEnglishSourceNode(mapped.node, mapped.parent)
if (!enSourceNode) {
window.$message.warning('找不到对应的英文源节点')
return
}
if (!hasTranslatableText(enSourceNode)) {
window.$message.warning('英文原文内容为空,无需翻译')
return
}
try {
translatingNodeId.value = mapped.node.id
store.saveSnapshot()
const ok = await translateNodePairs(enSourceNode, mapped.node)
if (ok) {
window.$message.success('翻译已成功填入')
store.rebuildNodeMap()
} else {
window.$message.error('智能翻译失败:接口未返回有效数据')
}
} catch (err: any) {
window.$message.error('翻译失败: ' + err.message)
} finally {
translatingNodeId.value = null
visible.value = false
}
}
// 检查当前选区并决定是否显示工具栏
const checkSelectionAndShow = () => {
const selection = window.getSelection()
if (!selection || selection.isCollapsed || !selection.toString().trim()) {
visible.value = false
return
}
const viewport = document.querySelector('.flex-1.overflow-y-auto.min-h-0.leading-relaxed')
if (!viewport) return
const anchorNode = selection.anchorNode
if (!anchorNode || !viewport.contains(anchorNode)) {
visible.value = false
return
}
let element: HTMLElement | null = null
if (anchorNode.nodeType === Node.TEXT_NODE) {
element = anchorNode.parentElement
} else if (anchorNode.nodeType === Node.ELEMENT_NODE) {
element = anchorNode as HTMLElement
}
const wrapper = element?.closest('[data-node-id]')
if (!wrapper) {
visible.value = false
return
}
const nodeId = wrapper.getAttribute('data-node-id')
if (!nodeId) {
visible.value = false
return
}
const range = selection.getRangeAt(0)
const rect = range.getBoundingClientRect()
if (rect.width === 0) {
visible.value = false
return
}
top.value = rect.top - 46
left.value = rect.left + rect.width / 2
selectedText.value = selection.toString()
let realId = nodeId
if (nodeId.includes('-txt-')) {
realId = nodeId.split('-txt-')[0]
}
const item = store.nodeMap.get(realId)
if (item) {
activeNodeId.value = nodeId
activeNode.value = item.node
parentNode.value = item.parent || null
visible.value = true
} else {
visible.value = false
}
}
// 监听鼠标按下事件:点击工具栏以外的地方时立即隐藏
const handleMousedown = (e: MouseEvent) => {
const toolbar = document.querySelector('.selection-floating-toolbar')
if (toolbar && toolbar.contains(e.target as Node)) {
return
}
visible.value = false
}
// 监听鼠标抬起事件:在选取完成后再判定是否弹窗
const handleMouseup = () => {
setTimeout(() => {
checkSelectionAndShow()
}, 10)
}
// 监听键盘按键释放:处理 Shift + 方向键选择文本的场景
const handleKeyup = (e: KeyboardEvent) => {
if (e.shiftKey || ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
checkSelectionAndShow()
}
}
// 滚动时重新计算位置或隐藏
const handleScroll = () => {
if (visible.value) {
const selection = window.getSelection()
if (selection && !selection.isCollapsed && selection.rangeCount > 0) {
const range = selection.getRangeAt(0)
const rect = range.getBoundingClientRect()
if (rect.width > 0) {
top.value = rect.top - 46
left.value = rect.left + rect.width / 2
return
}
}
visible.value = false
}
}
// ── 按钮事件处理 ──
const handleEditNode = () => {
if (!activeNode.value || !activeNodeId.value) return
addNodeTargetId.value = activeNodeId.value
addNodeMode.value = 'edit'
addNodeAllowedTags.value = [activeNode.value.tagName]
addNodeVisible.value = true
visible.value = false
}
const handleViewRule = () => {
if (!activeNode.value) return
const rule = getElementRule(activeNode.value.tagName)
checkRuleData.value = {
nodeName: activeNode.value.tagName,
rawModel: rule?.contentModel.raw || '(#PCDATA)',
humanReadable: rule?.contentModel.humanReadable || '',
parsed: rule?.contentModel.parsed || null
}
checkRuleVisible.value = true
visible.value = false
}
const handleAddChild = () => {
if (!activeNode.value) return
addNodeTargetId.value = activeNode.value.id
addNodeMode.value = 'child'
addNodeAllowedTags.value = getAllowedChildren(activeNode.value.tagName)
addNodeVisible.value = true
visible.value = false
}
const handleInsertBefore = () => {
if (!activeNode.value) return
if (isVirtualText.value) {
addNodeTargetId.value = activeNodeId.value!
addNodeMode.value = 'before'
addNodeAllowedTags.value = ['#text']
} else {
if (!parentNode.value) return
addNodeTargetId.value = activeNode.value.id
addNodeMode.value = 'before'
addNodeAllowedTags.value = getInsertableChildren(
parentNode.value.tagName,
parentNode.value.children.map((c) => c.tagName)
)
}
addNodeVisible.value = true
visible.value = false
}
const handleInsertAfter = () => {
if (!activeNode.value) return
if (isVirtualText.value) {
addNodeTargetId.value = activeNodeId.value!
addNodeMode.value = 'after'
addNodeAllowedTags.value = ['#text']
} else {
if (!parentNode.value) return
addNodeTargetId.value = activeNode.value.id
addNodeMode.value = 'after'
addNodeAllowedTags.value = getInsertableChildren(
parentNode.value.tagName,
parentNode.value.children.map((c) => c.tagName)
)
}
addNodeVisible.value = true
visible.value = false
}
const handleDeleteNode = () => {
if (!activeNodeId.value) return
store.deleteNode(activeNodeId.value)
window.$message?.success('成功删除节点')
visible.value = false
}
onMounted(() => {
document.addEventListener('mousedown', handleMousedown)
document.addEventListener('mouseup', handleMouseup)
document.addEventListener('keyup', handleKeyup)
const viewport = document.querySelector('.flex-1.overflow-y-auto.min-h-0.leading-relaxed')
if (viewport) {
viewport.addEventListener('scroll', handleScroll)
}
})
onBeforeUnmount(() => {
document.removeEventListener('mousedown', handleMousedown)
document.removeEventListener('mouseup', handleMouseup)
document.removeEventListener('keyup', handleKeyup)
const viewport = document.querySelector('.flex-1.overflow-y-auto.min-h-0.leading-relaxed')
if (viewport) {
viewport.removeEventListener('scroll', handleScroll)
}
})
return {
visible,
top,
left,
hasActiveNode,
isVirtualText,
hasParent,
hasAllowedChildren,
canDeleteCurrent,
canTranslateActive,
isTranslating,
handleEditNode,
handleViewRule,
handleTranslateNode,
handleAddChild,
handleInsertBefore,
handleInsertAfter,
handleDeleteNode
}
}
<template>
<Transition name="fade">
<div
v-if="visible && hasActiveNode"
ref="toolbarRef"
class="selection-floating-toolbar fixed z-50 flex items-center bg-card border border-divider shadow-xl rounded-lg px-2 py-1.5 space-x-1.5 backdrop-blur-md bg-opacity-95"
:style="{
top: `${top}px`,
left: `${left}px`,
transform: 'translateX(-50%)'
}"
@mousedown.prevent
>
<!-- 节点操作区域 -->
<div class="flex items-center space-x-1">
<!-- 编辑节点属性 -->
<n-tooltip trigger="hover">
<template #trigger>
<CommonButton
size="tiny"
quaternary
circle
:disabled="!hasActiveNode"
@click="handleEditNode"
>
<template #icon>
<n-icon><SettingsOutline /></n-icon>
</template>
</CommonButton>
</template>
编辑节点属性
</n-tooltip>
<!-- 查看规则 -->
<n-tooltip trigger="hover">
<template #trigger>
<CommonButton
size="tiny"
quaternary
circle
:disabled="!hasActiveNode || isVirtualText"
@click="handleViewRule"
>
<template #icon>
<n-icon><EyeOutline /></n-icon>
</template>
</CommonButton>
</template>
查看 DTD 规则
</n-tooltip>
<!-- 智能翻译 -->
<n-tooltip trigger="hover">
<template #trigger>
<CommonButton
size="tiny"
quaternary
circle
:disabled="!hasActiveNode || !canTranslateActive || isTranslating"
@click="handleTranslateNode"
>
<template #icon>
<n-icon v-if="isTranslating" class="animate-spin"><SyncOutline /></n-icon>
<n-icon v-else><LanguageOutline /></n-icon>
</template>
</CommonButton>
</template>
智能翻译
</n-tooltip>
<!-- 添加子节点 -->
<n-tooltip trigger="hover">
<template #trigger>
<CommonButton
size="tiny"
quaternary
circle
:disabled="!hasActiveNode || isVirtualText || !hasAllowedChildren"
@click="handleAddChild"
>
<template #icon>
<n-icon><AddCircleOutline /></n-icon>
</template>
</CommonButton>
</template>
添加子节点
</n-tooltip>
<!-- 插入到上方 -->
<n-tooltip trigger="hover">
<template #trigger>
<CommonButton
size="tiny"
quaternary
circle
:disabled="!hasActiveNode || !hasParent"
@click="handleInsertBefore"
>
<template #icon>
<n-icon><ArrowUpOutline /></n-icon>
</template>
</CommonButton>
</template>
插入到上方
</n-tooltip>
<!-- 插入到下方 -->
<n-tooltip trigger="hover">
<template #trigger>
<CommonButton
size="tiny"
quaternary
circle
:disabled="!hasActiveNode || !hasParent"
@click="handleInsertAfter"
>
<template #icon>
<n-icon><ArrowDownOutline /></n-icon>
</template>
</CommonButton>
</template>
插入到下方
</n-tooltip>
<!-- 删除节点 -->
<n-tooltip trigger="hover">
<template #trigger>
<CommonButton
size="tiny"
quaternary
circle
type="error"
:disabled="!hasActiveNode || !canDeleteCurrent"
@click="handleDeleteNode"
>
<template #icon>
<n-icon><TrashOutline /></n-icon>
</template>
</CommonButton>
</template>
删除节点
</n-tooltip>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import {
SettingsOutline,
EyeOutline,
AddCircleOutline,
ArrowUpOutline,
ArrowDownOutline,
TrashOutline,
LanguageOutline,
SyncOutline
} from '@vicons/ionicons5'
import { useSelectionToolbar } from './functionals/index'
const {
visible,
top,
left,
hasActiveNode,
isVirtualText,
hasParent,
hasAllowedChildren,
canDeleteCurrent,
canTranslateActive,
isTranslating,
handleEditNode,
handleViewRule,
handleTranslateNode,
handleAddChild,
handleInsertBefore,
handleInsertAfter,
handleDeleteNode
} = useSelectionToolbar()
</script>
<style scoped>
.selection-floating-toolbar {
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
border-color: var(--divider-color, rgba(0, 0, 0, 0.08));
transition: opacity 0.15s ease, transform 0.15s ease;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(5px) scale(0.95);
}
</style>
......@@ -69,6 +69,9 @@
<!-- 编辑区非 Table 元素右键上下文菜单弹窗 -->
<EditAreaContextMenuModal v-model="contextMenuVisible" :node-ids="contextMenuNodeIds" />
<!-- 文本选择悬浮工具栏 -->
<SelectionToolbar />
<!-- 回到顶部悬浮球 -->
<Transition name="fade">
<div
......@@ -94,6 +97,7 @@ import { useEditorStore } from '@/store/editor'
import DocNodeRenderer from '../DocNodeRenderer/index.vue'
import FindReplacePanel from './components/FindReplacePanel/index.vue'
import EditAreaContextMenuModal from './components/EditAreaContextMenuModal/index.vue'
import SelectionToolbar from './components/SelectionToolbar/index.vue'
import { useEditorPanel } from './functionals'
import { addNodeVisible, addNodeMode, addNodeTargetId, addNodeAllowedTags } from '../NodeTree/functionals'
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment