You've already forked SmartisanNote.Remake
392 lines
11 KiB
Vue
392 lines
11 KiB
Vue
<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 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()
|
||
}
|
||
}
|
||
|
||
// 处理星标切换事件
|
||
// 点击星标图标时调用父组件传递的回调函数
|
||
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
|
||
}
|
||
|
||
// 触摸开始事件处理函数
|
||
// 记录触摸开始时的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>
|