Files
SmartisanNote.Remake/src/components/NoteItem.vue

412 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="code-fun-flex-row code-fun-justify-center code-fun-relative list-item_7" @touchstart="handleContainerTouchStart" @touchmove="handleContainerTouchMove" @touchend="handleContainerTouchEnd">
<!-- 删除按钮 -->
<button class="btn_delete" @click.stop="handleDelete" :style="deleteButtonStyle">
<span>删除</span>
</button>
<!-- 便签条 -->
<div class="code-fun-flex-col code-fun-relative section_17" @click="handlePress" @touchstart="handleTouchStart" @touchmove="handleTouchMove" @touchend="handleTouchEnd" :style="{ transform: `translateX(${slideOffset}px)` }">
<div class="code-fun-flex-row code-fun-justify-between">
<!-- 便签编辑时间 -->
<span class="font_2 text_18">{{ formattedDate }}</span>
<div class="code-fun-flex-row group_3">
<!-- 是否置顶状态&置顶按钮 -->
<img class="image_11 image_29" :src="isTop ? '/assets/icons/drawable-xxhdpi/icon_top_checked.png' : '/assets/icons/drawable-xxhdpi/icon_top_normal.png'" @click.stop="handleTopToggle" />
<!-- 是否收藏状态&收藏按钮 -->
<img class="image_26 ml-5" :src="isStarred ? '/assets/icons/drawable-xxhdpi/icon_detail_star_checked.png' : '/assets/icons/drawable-xxhdpi/icon_detail_star_unchecked.png'" @click.stop="handleStarToggle" />
</div>
</div>
<div class="code-fun-flex-row code-fun-justify-between mt-17-5">
<!-- 便签正文第一行 -->
<span class="font_3 text_19">{{ displayContent }}</span>
<!-- 便签中是否存在图片 -->
<img v-if="hasImage" class="image_28" src="/assets/icons/drawable-xxhdpi/list_item_image_icon.png" />
</div>
</div>
<!-- 便签夹未滑动状态 -->
<img class="image_27 pos_18" :src="isSliding ? '/assets/icons/drawable-xxhdpi/note_item_clip_up.png' : '/assets/icons/drawable-xxhdpi/note_item_clip_normal.png'" />
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
const emit = defineEmits(['update:modelValue'])
const props = defineProps({
content: {
type: String,
required: true,
},
date: {
type: String,
required: true,
},
isStarred: {
type: Boolean,
default: false,
},
isTop: {
type: Boolean,
default: false,
},
hasImage: {
type: Boolean,
default: false,
},
onPress: {
type: Function,
default: () => {},
},
onStarToggle: {
type: Function,
default: () => {},
},
onTopToggle: {
type: Function,
default: () => {},
},
onDelete: {
type: Function,
default: () => {},
},
})
// 滑动相关状态
const slideOffset = ref(0)
const startX = ref(0)
const startY = ref(0) // 记录起始Y坐标
const isSliding = ref(false)
const isSlided = ref(false) // 是否已经滑动到阈值
const isScrolling = ref(false) // 是否正在滚动
const formattedDate = computed(() => {
// 直接返回已经格式化的日期字符串
// 日期格式化已在父组件中完成
return props.date
})
// 处理显示内容过滤HTML标签并只显示第一行
// 用于在便签列表中显示便签的预览内容
const displayContent = computed(() => {
// 过滤HTML标签只保留纯文本内容
let text = props.content.replace(/<[^>]*>/g, '')
// 处理换行符,统一为\n
text = text.replace(/\\n/g, '\n')
// 按换行符分割并获取第一行
const lines = text.split('\n')
// 返回第一行内容,如果为空则显示默认文本
return lines[0]?.trim() || '无内容'
})
// 计算删除按钮的样式
const deleteButtonStyle = computed(() => {
return {
opacity: isSlided.value ? 1 : 0,
pointerEvents: isSlided.value ? 'auto' : 'none',
}
})
// 滑动阈值(删除按钮宽度)
// 当滑动距离超过此值时,显示删除按钮
const SLIDE_THRESHOLD = 64 // 4rem 转换为 px
// 容器触摸事件处理函数
const handleContainerTouchStart = e => {
// 阻止事件冒泡到父组件
e.stopPropagation()
// 阻止父级滚动容器的滚动行为
e.stopImmediatePropagation()
}
const handleContainerTouchMove = e => {
// 阻止事件冒泡到父组件
e.stopPropagation()
// 阻止父级滚动容器的滚动行为
e.stopImmediatePropagation()
}
const handleContainerTouchEnd = e => {
// 阻止事件冒泡到父组件
e.stopPropagation()
// 阻止父级滚动容器的滚动行为
e.stopImmediatePropagation()
}
// 处理便签点击事件
// 只有在未滑动状态下才触发点击事件,避免与滑动操作冲突
const handlePress = () => {
// 只有在未滑动状态下才触发点击事件
if (slideOffset.value === 0 && props.onPress) {
props.onPress()
} else if (slideOffset.value !== 0) {
// 如果当前处于滑动状态,重置滑动状态(收回便签条)
resetSlideState()
}
}
// 处理星标切换事件
// 点击星标图标时调用父组件传递的回调函数
const handleStarToggle = () => {
if (props.onStarToggle) {
props.onStarToggle()
}
}
// 处理置顶切换事件
// 点击置顶图标时调用父组件传递的回调函数
const handleTopToggle = () => {
if (props.onTopToggle) {
props.onTopToggle()
}
}
// 处理删除事件
// 点击删除按钮时调用父组件传递的回调函数
const handleDelete = () => {
// 阻止事件冒泡,避免触发便签条的点击事件
props.onDelete()
// 重置滑动状态
slideOffset.value = 0
isSliding.value = false
isSlided.value = false
}
// 重置滑动状态
const resetSlideState = () => {
slideOffset.value = 0
isSliding.value = false
isSlided.value = false
}
// 获取滑动状态
const getSlideState = () => {
return isSlided.value
}
// 暴露方法给父组件
defineExpose({ resetSlideState, getSlideState })
// 触摸开始事件处理函数
// 记录触摸开始时的X坐标用于计算滑动距离
const handleTouchStart = e => {
// 阻止事件冒泡到父组件,防止页面滚动时触发便签滑动
e.stopPropagation()
// 阻止父级滚动容器的滚动行为
e.stopImmediatePropagation()
// 重置滑动状态
startX.value = e.touches[0].clientX
startY.value = e.touches[0].clientY // 记录起始Y坐标
isSliding.value = false
isSlided.value = false
isScrolling.value = false // 重置滚动状态
}
// 触摸移动事件处理函数
// 根据手指移动距离计算便签条的水平偏移量
const handleTouchMove = e => {
if (!startX.value || !startY.value) return
const currentX = e.touches[0].clientX
const currentY = e.touches[0].clientY
const diffX = currentX - startX.value
const diffY = currentY - startY.value
// 如果已经确定是滚动,则不再处理
if (isScrolling.value) {
// 阻止事件冒泡到父组件,防止页面滚动时触发便签滑动
e.stopPropagation()
return
}
// 判断是滚动还是滑动操作
// 如果Y轴移动距离大于X轴移动距离则认为是滚动
if (Math.abs(diffY) > Math.abs(diffX)) {
isScrolling.value = true
// 阻止事件冒泡到父组件,防止页面滚动时触发便签滑动
e.stopPropagation()
return
}
// 只处理右滑动(正值)
if (diffX > 0) {
// 阻止事件冒泡到父组件,防止页面滚动时触发便签滑动
e.stopPropagation()
// 阻止父级滚动容器的滚动行为
e.stopImmediatePropagation()
// 只有当滑动达到一定距离时才阻止页面滚动
if (diffX > 5) {
e.preventDefault() // 防止页面滚动
}
// 设置滑动状态
isSliding.value = true
// 应用阻尼效果,使超过阈值后的滑动更加困难
let offset = 0
if (diffX <= SLIDE_THRESHOLD) {
// 线性滑动,在阈值内正常滑动
offset = diffX
} else {
// 超过阈值后应用阻尼效果,增加滑动阻力
const excess = diffX - SLIDE_THRESHOLD
offset = SLIDE_THRESHOLD + excess * 0.03 // 0.03 为阻尼系数
}
slideOffset.value = offset
isSlided.value = offset >= SLIDE_THRESHOLD
} else if (diffX < 0) {
// 左滑动,将便签条移回原位
const offset = Math.max(0, slideOffset.value + diffX)
slideOffset.value = offset
isSlided.value = offset >= SLIDE_THRESHOLD
// 更新 startX 以确保连续滑动的正确性
startX.value = currentX
}
}
// 触摸结束事件处理函数
// 根据滑动距离决定便签条的最终位置
const handleTouchEnd = e => {
if (!startX.value) return
// 阻止事件冒泡到父组件,防止页面滚动时触发便签滑动
e?.stopPropagation()
// 阻止父级滚动容器的滚动行为
e?.stopImmediatePropagation()
// 如果滑动超过阈值,保持滑出状态;否则回弹
if (slideOffset.value >= SLIDE_THRESHOLD) {
// 保持滑出状态,显示删除按钮
slideOffset.value = SLIDE_THRESHOLD
isSlided.value = true
} else {
// 回弹到初始位置
slideOffset.value = 0
isSliding.value = false
isSlided.value = false
}
// 重置起始位置和滚动状态
startX.value = 0
startY.value = 0
isScrolling.value = false
}
</script>
<style lang="less" scoped>
.mt-17-5 {
margin-top: -1rem;
}
.btn_delete {
background: url(/assets/icons/drawable-xxhdpi/btn_slide_delete_normal.png);
background-size: cover;
width: 4rem;
height: 2rem;
position: absolute;
top: 50%;
left: 1rem;
z-index: 1;
transform: translate(0, -50%);
color: white;
text-align: right;
border: none;
padding: 0;
transition: opacity 0.2s ease;
span {
margin-right: 0.7rem;
font-size: 0.6rem;
display: block;
}
}
.list-item_7 {
width: 100%;
height: 3.16rem;
touch-action: pan-y; /* 只允许垂直滚动,禁止水平滑动 */
.section_17 {
box-sizing: border-box;
width: 95%;
height: 100%;
background: linear-gradient(to bottom, #fffdf6, #f3eee4);
z-index: 2;
padding: 0.44rem 0.69rem 0.88rem 2.09rem;
box-shadow: 0 2px 2px 1px rgb(0 0 0 / 15%);
transition: transform 0.2s ease-out;
border-radius: 3px;
&:before {
content: '';
width: 1.5rem;
height: 100%;
position: absolute;
left: 0;
top: 0;
background: linear-gradient(to bottom, #f9f5ee, #eee6dc);
}
.font_2 {
font-size: 0.71rem;
line-height: 0.71rem;
color: #c2bdb1;
}
.text_18 {
margin-top: 0.061rem;
color: #c3beb4;
}
.group_3 {
margin-right: 0.19rem;
margin-bottom: 0.063rem;
.image_11 {
width: 2.2rem;
height: 2.2rem;
object-fit: contain;
position: relative;
top: -0.15rem;
}
.image_29 {
margin-top: 0.063rem;
}
.image_26 {
width: 1.2rem;
height: 1.2rem;
}
}
.font_3 {
font-size: 0.88rem;
line-height: 0.88rem;
}
.text_19 {
margin-bottom: 0.065rem;
color: #816d61;
font-size: 0.9rem;
line-height: 0.9rem;
word-break: break-all;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
-webkit-line-clamp: 1;
}
.image_28 {
width: 1.06rem;
height: 0.91rem;
}
}
.image_27 {
width: 1.7rem;
height: 1.7rem;
object-fit: cover;
}
.pos_18 {
position: absolute;
left: 0;
top: 50%;
transform: translate(0, -50%);
z-index: 3;
transition: transform 0.3s ease-out;
}
}
</style>