add subtile animation

This commit is contained in:
Felix
2025-11-29 10:53:00 +08:00
parent 3bac35bdbb
commit a3d28e99e3
3 changed files with 198 additions and 5 deletions

View File

@@ -118,6 +118,13 @@ interface IPageData {
showBackIcon: boolean // 是否显示返回图标
previousWord: string // 上一个单词
isReturningFromPrevious: boolean // 是否正在从上一个单词返回
overlayVisible: boolean,
highlightWords: string[],
highlightShow: boolean,
highlightZoom: boolean,
focusWordIndex: number,
focusTransform: string,
cachedHighlightWords: string[],
isWordEmptyResult: boolean
}
@@ -150,6 +157,8 @@ type IPageMethods = {
getTransIconName: () => string
onPageScroll: (e: any) => void
processCollinsData: (collinsData: any) => any // Add this line
computeHighlightLayout:() => void
onMicHighlight: () => void
onMoreTap: () => void
}
@@ -231,7 +240,6 @@ Page<IPageData, IPageInstance>({
scoreModalVisible: false,
justSelectedByWord: false,
isMoreMenuOpen: false, // 更多菜单是否打开
isMoreMenuClosing: false,
displayTotalScore: 0,
displayAccuracyScore: 0,
displayCompletenessScore: 0,
@@ -242,9 +250,56 @@ Page<IPageData, IPageInstance>({
showBackIcon: false, // 是否显示返回图标
previousWord: '', // 上一个单词
isReturningFromPrevious: false, // 是否正在从上一个单词返回
isWordEmptyResult: false
isWordEmptyResult: false,
overlayVisible: false,
highlightWords: [],
highlightShow: false,
highlightZoom: false,
focusWordIndex: 0,
focusTransform: '',
cachedHighlightWords: [],
isMoreMenuClosing: false
},
onMicHighlight() {
const pre = this.data.cachedHighlightWords || []
if (!pre || pre.length === 0) {
this.computeHighlightLayout()
}
const words = this.data.cachedHighlightWords || []
if (!words || words.length === 0) return
this.setData({
overlayVisible: true,
highlightWords: words.map((w: any) => ({ ...w, transform: 'translate(0px, 0px) scale(1)' })),
highlightShow: false,
highlightZoom: false
})
setTimeout(() => {
this.setData({ highlightShow: true })
setTimeout(() => {
this.setData({ highlightZoom: true })
try {
wx.nextTick(() => {
const updated = (this.data.highlightWords || []).map((w: any) => ({
...w,
transform: w.targetTransform
}))
this.setData({ highlightWords: updated })
})
} catch (e) {
setTimeout(() => {
const updated = (this.data.highlightWords || []).map((w: any) => ({
...w,
transform: w.targetTransform
}))
this.setData({ highlightWords: updated })
}, 0)
}
}, 500)
}, 50)
},
// 切换评分区域展开状态
toggleScoreSection() {
this.setData({
@@ -458,12 +513,19 @@ Page<IPageData, IPageInstance>({
// 长按开始录音
handleRecordStart() {
this.startRecording()
try { this.onMicHighlight() } catch (e) {}
},
// 松开结束录音
handleRecordEnd() {
console.log('---lisa-handleRecordEnd')
this.stopRecording()
// 淡出高亮层
this.setData({ overlayVisible: false })
const timer = setTimeout(() => {
this.setData({ highlightShow: false, highlightZoom: false, focusTransform: '', highlightWords: [] })
clearTimeout(timer)
}, 320)
},
// 点击图片预览
@@ -1001,6 +1063,14 @@ Page<IPageData, IPageInstance>({
if (currentSentence.id) {
this.getStandardVoice(currentSentence.id)
}
try {
wx.nextTick(() => {
this.computeHighlightLayout()
})
} catch (e) {
setTimeout(() => this.computeHighlightLayout(), 0)
}
},
onLoad(options: Record<string, string>) {
@@ -1160,6 +1230,12 @@ Page<IPageData, IPageInstance>({
recorderManager.onError((res) => {
wx.showToast({ title: '录音失败', icon: 'none' })
// 录音出错时淡出高亮层
this.setData({ overlayVisible: false })
const timer = setTimeout(() => {
this.setData({ highlightShow: false, highlightZoom: false, focusTransform: '', highlightWords: [] })
clearTimeout(timer)
}, 320)
})
recorderHandlersBound = true
@@ -1525,7 +1601,90 @@ Page<IPageData, IPageInstance>({
const timer = setTimeout(() => {
this.setData({ isMoreMenuClosing: false })
clearTimeout(timer)
}, 360)
}, 360)
},
computeHighlightLayout() {
const { processedSentences, selectedSentenceIndex } = this.data
if (!processedSentences || processedSentences.length === 0) return
const sentence = processedSentences[selectedSentenceIndex]
const words = sentence?.words || []
if (!words || words.length === 0) return
const sys = wx.getSystemInfoSync()
const windowWidth = sys.windowWidth || 375
const windowHeight = sys.windowHeight || 667
const bottomOffset = 120
const scale = 2.5
const centerX = windowWidth / 2
const centerY = (windowHeight - bottomOffset) / 2
const query = wx.createSelectorQuery().in(this as any)
query.selectAll('.sentence-wrapper.selected .sentence-text').boundingClientRect()
query.exec((res: any) => {
const rects = (res && res[0]) || []
if (!rects || rects.length === 0) return
const sidePadding = 24
const wordSpacing = 12
const rowSpacing = 16
const availWidth = Math.max(windowWidth - sidePadding * 2, 100)
const scaledHeights = rects.map((r: any) => r.height * scale)
const rowHeight = Math.max(...scaledHeights)
const scaledWidths = rects.map((r: any) => Math.max(r.width * scale, 10))
const rows: { idxs: number[], width: number }[] = []
let current: { idxs: number[], width: number } = { idxs: [], width: 0 }
scaledWidths.forEach((w, i) => {
const extra = current.idxs.length > 0 ? wordSpacing : 0
if (current.width + extra + w <= availWidth) {
current.idxs.push(i)
current.width += extra + w
} else {
if (current.idxs.length > 0) rows.push(current)
current = { idxs: [i], width: w }
}
})
if (current.idxs.length > 0) rows.push(current)
const totalHeight = rows.length * rowHeight + Math.max(rows.length - 1, 0) * rowSpacing
const firstRowCenterY = centerY - totalHeight / 2 + rowHeight / 2
const targetWords = rects.map((r: any, idx: number) => {
const rcx = r.left + r.width / 2
const rcy = r.top + r.height / 2
let rowIndex = 0
let y = firstRowCenterY
for (let ri = 0; ri < rows.length; ri++) {
if (rows[ri].idxs.includes(idx)) { rowIndex = ri; break }
y += rowHeight + rowSpacing
}
const row = rows[rowIndex]
const rowStartX = centerX - row.width / 2
let cumX = 0
for (const j of row.idxs) {
if (j === idx) break
cumX += scaledWidths[j] + wordSpacing
}
const targetCx = rowStartX + cumX + scaledWidths[idx] / 2
const targetCy = firstRowCenterY + rowIndex * (rowHeight + rowSpacing)
const dx = targetCx - rcx
const dy = targetCy - rcy
const transform = `translate(${dx}px, ${dy}px) scale(${scale})`
return {
text: words[idx] || '',
left: r.left,
top: r.top,
width: r.width,
height: r.height,
targetTransform: transform,
transform: 'translate(0px, 0px) scale(1)'
}
})
this.setData({ cachedHighlightWords: targetWords })
})
},
// 返回上一个单词的查询结果
@@ -1550,4 +1709,4 @@ Page<IPageData, IPageInstance>({
// 查询上一个单词
this.handleWordClick(event);
}
})
})

View File

@@ -29,6 +29,13 @@
</view>
</view>
</view>
<view class="highlight-area {{overlayVisible ? 'show' : ''}}">
<view wx:for="{{highlightWords}}" wx:key="index"
class="overlay-word {{highlightShow ? 'show' : ''}} {{highlightZoom ? 'zoom' : ''}}"
style="left: {{item.left}}px; top: {{item.top}}px; width: {{item.width}}px; height: {{item.height}}px; {{item.transform ? ('transform: ' + item.transform) : ''}}">
<text class="overlay-text">{{item.text}}</text>
</view>
</view>
<!-- 底部按钮区域 -->
<view class="bottom-button-area">
<view wx:if="{{isMoreMenuOpen}}" class="more-menu-modal"></view>
@@ -38,7 +45,7 @@
<t-icon name="translate" class="trans-button left-half {{transDisplayMode === 'en_ipa' ? 'trans-active' : 'trans-deactive'}}" size="48rpx" />
<t-icon name="translate" class="trans-button right-half {{transDisplayMode === 'en_zh' ? 'trans-active' : 'trans-deactive'}}" size="48rpx" />
</view>
<view class="bottom-button mic-wrap">
<view class="bottom-button mic-wrap" bindtap="onMicHighlight">
<t-icon name="microphone-1" color="{{isRecording ? '#FFFFFF' : '#333333'}}" class="microphone {{isRecording ? 'recording' : 'bottom-button'}}" size="48rpx" bind:longpress="handleRecordStart" bind:touchend="handleRecordEnd" bind:touchcancel="handleRecordEnd" />
<view wx:if="{{isRecording}}" class="mic">
<view class="mic-shadow"></view>

View File

@@ -546,6 +546,33 @@
z-index: 2 !important;
}
/* 高亮遮罩与单词浮层 */
.highlight-area {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: calc(120rpx + env(safe-area-inset-bottom));
background: rgba(0,0,0,0.6);
z-index: 99;
pointer-events: none;
opacity: 0;
transition: opacity 300ms ease;
}
.highlight-area.show { opacity: 1; }
.overlay-word {
position: fixed;
color: #ffffff;
font-size: 32rpx;
line-height: 32rpx;
opacity: 0;
transition: opacity 500ms ease;
}
.overlay-word .overlay-text { color: #ffffff; }
.overlay-word.show { opacity: 1; }
.overlay-word.show .overlay-text { font-weight: 600; }
.overlay-word.zoom { transform-origin: center center; transition: transform 500ms ease; }
.bottom-button {
padding: 20rpx;
border-radius: 50%;