-
Notifications
You must be signed in to change notification settings - Fork 286
feat:划词翻译跟随元素滚动 #151
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Open
feat:划词翻译跟随元素滚动 #151
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,80 +1,80 @@ | ||
| <template> | ||
| <teleport to="body"> | ||
| <!-- 小红点指示器 --> | ||
| <div v-if="showIndicator" | ||
| class="fr-selection-indicator" | ||
| :style="indicatorStyle" | ||
| @mouseenter="handleMouseEnter" | ||
| @mouseleave="handleMouseLeave"> | ||
| </div> | ||
|
|
||
| <!-- 翻译结果弹窗 --> | ||
| <div v-if="showTooltip" | ||
| class="fr-translation-tooltip" | ||
| :class="{ 'fr-dark-theme': isDarkTheme }" | ||
| :style="tooltipStyle" | ||
| @mouseenter="handleMouseEnterTooltip" | ||
| @mouseleave="handleMouseLeaveTooltip"> | ||
| <div class="fr-tooltip-header"> | ||
| <span>翻译结果<small>(via 流畅阅读)</small></span> | ||
| <div class="fr-tooltip-actions"> | ||
| <button class="fr-action-btn" @click="copyTranslation" title="复制译文"> | ||
| <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | ||
| <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect> | ||
| <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path> | ||
| </svg> | ||
| </button> | ||
| <button class="fr-close-btn" @click="closeTooltip"&g×ばつ</button> | ||
| </div> | ||
| <div ref="selection-ref" class="fr-selection-translator-wrapper"> | ||
| <!-- 小红点指示器 --> | ||
| <div v-if="showIndicator" | ||
| class="fr-selection-indicator" | ||
| @mouseenter="handleMouseEnter" | ||
| @mouseleave="handleMouseLeave"> | ||
| </div> | ||
| <div class="fr-tooltip-content"> | ||
| <div v-if="isLoading" :class="['fr-loading-spinner', { 'fr-static': !config.animations }]"></div> | ||
| <div v-else-if="error" class="fr-error-message">{{ error }}</div> | ||
| <div v-else class="fr-translation-container"> | ||
| <!-- 原文显示(双语模式才显示) --> | ||
| <div v-if="config.selectionTranslatorMode === 'bilingual'" class="fr-original-text fr-no-select"> | ||
| <pre>{{ selectedText }}</pre> | ||
| <button class="fr-text-audio-btn" @click="(e) => toggleAudio(selectedText, e)" title="播放/停止原文"> | ||
| <svg v-if="isPlaying && currentPlayingText === selectedText" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | ||
| <rect x="6" y="4" width="4" height="16"></rect> | ||
| <rect x="14" y="4" width="4" height="16"></rect> | ||
| </svg> | ||
| <svg v-else xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | ||
| <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon> | ||
| <path d="M19.07 4.93a10 10 0 0 1 0 14.14"></path> | ||
| </svg> | ||
| </button> | ||
| </div> | ||
| <!-- 译文显示(双语模式和只显示译文模式都显示) --> | ||
| <div v-if="config.selectionTranslatorMode === 'bilingual' || config.selectionTranslatorMode === 'translation-only'" class="fr-translation-result fr-no-select"> | ||
| <pre>{{ translationResult }}</pre> | ||
| <button class="fr-text-audio-btn" @click="(e) => toggleAudio(translationResult, e)" title="播放/停止译文"> | ||
| <svg v-if="isPlaying && currentPlayingText === translationResult" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | ||
| <rect x="6" y="4" width="4" height="16"></rect> | ||
| <rect x="14" y="4" width="4" height="16"></rect> | ||
| </svg> | ||
| <svg v-else xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | ||
| <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon> | ||
| <path d="M15.54 8.46a5 5 0 0 1 0 7.07"></path> | ||
|
|
||
| <!-- 翻译结果弹窗 --> | ||
| <div v-if="showTooltip" | ||
| class="fr-translation-tooltip" | ||
| :class="{ 'fr-dark-theme': isDarkTheme }" | ||
| @mouseenter="handleMouseEnterTooltip" | ||
| @mouseleave="handleMouseLeaveTooltip"> | ||
| <div class="fr-tooltip-header"> | ||
| <span>翻译结果<small>(via 流畅阅读)</small></span> | ||
| <div class="fr-tooltip-actions"> | ||
| <button class="fr-action-btn" @click="copyTranslation" title="复制译文"> | ||
| <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | ||
| <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect> | ||
| <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path> | ||
| </svg> | ||
| </button> | ||
| <button class="fr-close-btn" @click="closeTooltip"&g×ばつ</button> | ||
| </div> | ||
|
|
||
| <!-- 播放状态提示 - 显示在弹窗内部 --> | ||
| <div v-if="isPlaying" class="fr-playing-status"> | ||
| <div class="fr-playing-status-icon"> | ||
| <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | ||
| <path d="M3 18v-6a9 9 0 0 1 18 0v6"></path> | ||
| <path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"></path> | ||
| </svg> | ||
| </div> | ||
| <div class="fr-tooltip-content"> | ||
| <div v-if="isLoading" :class="['fr-loading-spinner', { 'fr-static': !config.animations }]"></div> | ||
| <div v-else-if="error" class="fr-error-message">{{ error }}</div> | ||
| <div v-else class="fr-translation-container"> | ||
| <!-- 原文显示(双语模式才显示) --> | ||
| <div v-if="config.selectionTranslatorMode === 'bilingual'" class="fr-original-text fr-no-select"> | ||
| <pre>{{ selectedText }}</pre> | ||
| <button class="fr-text-audio-btn" @click="(e) => toggleAudio(selectedText, e)" title="播放/停止原文"> | ||
| <svg v-if="isPlaying && currentPlayingText === selectedText" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | ||
| <rect x="6" y="4" width="4" height="16"></rect> | ||
| <rect x="14" y="4" width="4" height="16"></rect> | ||
| </svg> | ||
| <svg v-else xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | ||
| <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon> | ||
| <path d="M19.07 4.93a10 10 0 0 1 0 14.14"></path> | ||
| </svg> | ||
| </button> | ||
| </div> | ||
| <!-- 译文显示(双语模式和只显示译文模式都显示) --> | ||
| <div v-if="config.selectionTranslatorMode === 'bilingual' || config.selectionTranslatorMode === 'translation-only'" class="fr-translation-result fr-no-select"> | ||
| <pre>{{ translationResult }}</pre> | ||
| <button class="fr-text-audio-btn" @click="(e) => toggleAudio(translationResult, e)" title="播放/停止译文"> | ||
| <svg v-if="isPlaying && currentPlayingText === translationResult" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | ||
| <rect x="6" y="4" width="4" height="16"></rect> | ||
| <rect x="14" y="4" width="4" height="16"></rect> | ||
| </svg> | ||
| <svg v-else xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | ||
| <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon> | ||
| <path d="M15.54 8.46a5 5 0 0 1 0 7.07"></path> | ||
| </svg> | ||
| </button> | ||
| </div> | ||
|
|
||
| <!-- 播放状态提示 - 显示在弹窗内部 --> | ||
| <div v-if="isPlaying" class="fr-playing-status"> | ||
| <div class="fr-playing-status-icon"> | ||
| <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | ||
| <path d="M3 18v-6a9 9 0 0 1 18 0v6"></path> | ||
| <path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"></path> | ||
| </svg> | ||
| </div> | ||
| <span>正在播放: {{ currentPlayingText === selectedText ? '原文' : '译文' }}</span> | ||
| <button class="fr-stop-audio-btn" @click="(e) => stopAudio(e)"> | ||
| <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | ||
| <rect x="6" y="4" width="4" height="16"></rect> | ||
| <rect x="14" y="4" width="4" height="16"></rect> | ||
| </svg> | ||
| </button> | ||
| </div> | ||
| <span>正在播放: {{ currentPlayingText === selectedText ? '原文' : '译文' }}</span> | ||
| <button class="fr-stop-audio-btn" @click="(e) => stopAudio(e)"> | ||
| <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | ||
| <rect x="6" y="4" width="4" height="16"></rect> | ||
| <rect x="14" y="4" width="4" height="16"></rect> | ||
| </svg> | ||
| </button> | ||
| </div> | ||
| </div> | ||
| </div> | ||
|
|
@@ -93,14 +93,15 @@ | |
| </template> | ||
|
|
||
| <script setup lang="ts"> | ||
| import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'; | ||
| import { ref, onMounted, onBeforeUnmount, watch, useTemplateRef, watchEffect } from 'vue'; | ||
| import { translateText } from '@/entrypoints/utils/translateApi'; | ||
| import { config } from '@/entrypoints/utils/config'; | ||
| import { autoPlacement, autoUpdate, computePosition, flip, hide, inline, offset, shift } from '@floating-ui/dom'; | ||
|
|
||
| // 状态变量 | ||
| const selectedText = ref(''); | ||
| const translationResult = ref(''); | ||
| const selectionRect = ref<DOMRect | null>(null); | ||
| const selectRange = ref<Range | null>(null); | ||
| const showIndicator = ref(false); | ||
| const showTooltip = ref(false); | ||
| const isLoading = ref(false); | ||
|
|
@@ -117,34 +118,35 @@ const currentPlayingText = ref(''); // 当前正在播放的文本 | |
| const isFirefox = ref(false); // 是否为Firefox浏览器 | ||
| const isDarkTheme = ref(false); // 主题状态 | ||
|
|
||
| // 计算小红点指示器的样式 | ||
| const indicatorStyle = computed(() => { | ||
| if (!selectionRect.value) return {}; | ||
|
|
||
| return { | ||
| left: `${selectionRect.value.right}px`, | ||
| top: `${selectionRect.value.top}px`, | ||
| transform: 'translate(3px, -50%)' | ||
| }; | ||
| }); | ||
| const containerRef = useTemplateRef('selection-ref'); | ||
|
|
||
| // 自动更新小红点位置 | ||
| watchEffect((onClean) => { | ||
| const range = selectRange.value; | ||
| const container = containerRef.value; | ||
| if (!range || !container) return; | ||
|
|
||
| const updatePosition = () => { | ||
| computePosition(range, container, { | ||
| placement: 'right', | ||
| strategy: 'fixed', | ||
| middleware: [offset(2), flip({fallbackPlacements: ['left', 'right', 'top-start', 'top-end', 'bottom-start', 'bottom-end'], padding: {top: 100, bottom: 100} }), shift(), hide(), inline()], | ||
| }).then(({ x, y, placement, middlewareData }) => { | ||
| Object.assign(container.style, { | ||
| left: `${x}px`, | ||
| top: `${y}px`, | ||
| visibility: middlewareData.hide?.referenceHidden ? 'hidden' : 'visible', | ||
| }); | ||
| container.setAttribute('data-placement', placement); | ||
| }) | ||
| } | ||
|
|
||
| // 计算弹窗的样式 | ||
| const tooltipStyle = computed(() => { | ||
| if (!selectionRect.value) return {}; | ||
|
|
||
| // 确保弹窗不会超出视口 | ||
| const left = Math.min( | ||
| selectionRect.value.right + 15, | ||
| window.innerWidth - 350 // 稍微增加宽度,适应换行文本 | ||
| ); | ||
|
|
||
| return { | ||
| left: `${left}px`, | ||
| top: `${selectionRect.value.top}px`, | ||
| maxWidth: '350px', // 增加宽度以适应更多内容 | ||
| maxHeight: '400px' // 增加最大高度以支持更多内容 | ||
| }; | ||
| }); | ||
| const cb = autoUpdate(range, container, updatePosition, { | ||
| animationFrame: true, | ||
| }); | ||
|
|
||
| onClean(cb); | ||
| }) | ||
|
|
||
| // 防抖函数 | ||
| const debounce = (fn: Function, delay: number) => { | ||
|
|
@@ -180,8 +182,7 @@ const handleTextSelection = () => { | |
| if (selectedTextContent === lastSelectedText.value) { | ||
| // 重新显示指示器,但不重新获取翻译 | ||
| const range = selection.getRangeAt(0); | ||
| const rect = range.getBoundingClientRect(); | ||
| selectionRect.value = rect; | ||
| selectRange.value = range; | ||
| showIndicator.value = true; | ||
| return; | ||
| } | ||
|
|
@@ -201,12 +202,11 @@ const handleTextSelection = () => { | |
|
|
||
| // 获取选中文本位置信息 | ||
| const range = selection.getRangeAt(0); | ||
| const rect = range.getBoundingClientRect(); | ||
|
|
||
| // 保存选中文本和位置 | ||
| selectedText.value = selectedTextContent; | ||
| lastSelectedText.value = selectedTextContent; | ||
| selectionRect.value = rect; | ||
| selectRange.value = range; | ||
| showIndicator.value = true; | ||
| }, 200); // 200ms防抖延迟,减少延迟提高响应性 | ||
| }; | ||
|
|
@@ -671,8 +671,16 @@ onBeforeUnmount(() => { | |
| </script> | ||
|
|
||
| <style scoped> | ||
| .fr-selection-indicator { | ||
| .fr-selection-translator-wrapper { | ||
| position: fixed; | ||
| top: 0; | ||
| left: 0; | ||
| z-index: 9998; | ||
| width: 350px; | ||
| } | ||
|
Comment on lines
+679
to
+680
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: Hardcoding the wrapper width to 350px may reduce flexibility. A fixed width may not display well on all devices or with varying content lengths. Making the width responsive or configurable would improve adaptability.
Suggested change
width: 350px;
}
width: var(--translator-width, 100%);
max-width: 350px;
}
|
||
|
|
||
| .fr-selection-indicator { | ||
| position: absolute; | ||
| width: 12px; | ||
| height: 12px; | ||
| background-color: #ff4d4f; | ||
|
|
@@ -683,23 +691,48 @@ onBeforeUnmount(() => { | |
| animation: pulse 1.5s infinite; | ||
| } | ||
|
|
||
| [data-placement="left"] .fr-selection-indicator { | ||
| bottom: 0; | ||
| right: 4px; | ||
| } | ||
| [data-placement="right"] .fr-selection-indicator { | ||
| bottom: 0; | ||
| left: 4px; | ||
| } | ||
| [data-placement="top-start"] .fr-selection-indicator { | ||
| left: 0; | ||
| bottom: 4px; | ||
| } | ||
| [data-placement="top-end"] .fr-selection-indicator { | ||
| right: 0; | ||
| bottom: 4px; | ||
| } | ||
| [data-placement="bottom-start"] .fr-selection-indicator { | ||
| left: 0; | ||
| top: 4px; | ||
| } | ||
| [data-placement="bottom-end"] .fr-selection-indicator { | ||
| right: 0; | ||
| top: 4px; | ||
| } | ||
|
|
||
| @keyframes pulse { | ||
| 0% { | ||
| transform: translate(10px, -50%) scale(1); | ||
| transform: scale(1); | ||
| box-shadow: 0 0 0 0 rgba(255, 77, 79, 0.7); | ||
| } | ||
| 70% { | ||
| transform: translate(10px, -50%) scale(1.1); | ||
| transform: scale(1.1); | ||
| box-shadow: 0 0 0 10px rgba(255, 77, 79, 0); | ||
| } | ||
| 100% { | ||
| transform: translate(10px, -50%) scale(1); | ||
| transform: scale(1); | ||
| box-shadow: 0 0 0 0 rgba(255, 77, 79, 0); | ||
| } | ||
| } | ||
|
|
||
| .fr-translation-tooltip { | ||
| position: fixed; | ||
| position: absolute; | ||
| background-color: white !important; | ||
| border-radius: 8px; | ||
| box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15); | ||
|
|
@@ -712,6 +745,31 @@ onBeforeUnmount(() => { | |
| transition: opacity 0.2s ease; | ||
| } | ||
|
|
||
| [data-placement="left"] .fr-translation-tooltip { | ||
| top: -10px; | ||
| right: 5px; | ||
| } | ||
| [data-placement="right"] .fr-translation-tooltip { | ||
| top: -10px; | ||
| left: 5px; | ||
| } | ||
| [data-placement="top-start"] .fr-translation-tooltip { | ||
| left: 1px; | ||
| bottom: 5px; | ||
| } | ||
| [data-placement="top-end"] .fr-translation-tooltip { | ||
| right: 1px; | ||
| bottom: 5px; | ||
| } | ||
| [data-placement="bottom-start"] .fr-translation-tooltip { | ||
| left: 1px; | ||
| top: 5px; | ||
| } | ||
| [data-placement="bottom-end"] .fr-translation-tooltip { | ||
| right: 1px; | ||
| top: 5px; | ||
| } | ||
|
|
||
| .fr-tooltip-header { | ||
| padding: 8px 12px; | ||
| background-color: #f5f5f5 !important; | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.