FrontTools/InputArea/InputArea.vue

742 lines
21 KiB
Vue
Raw Permalink Normal View History

<template>
<view class="input-wrapper" :style="{ '--main-color': themeColor , '--bar-height' :barHeight , 'padding-bottom': dynamicBottom }">
<!-- 引用条 + 输入框纵向 flex引用条在上输入框在下 -->
<view class="input-bar-group">
<!-- 引用回复条 -->
<view v-if="isReplyValid" class="reply-bar" :style="{ '--dynamic-radius': dynamicRadius + 'rpx' }" @touchstart.prevent>
<view class="reply-bar-inner">
<view class="reply-text-wrap">
<text class="reply-name">{{ replyDisplayName }}</text>
<text class="reply-content">{{ replyDisplayContent }}</text>
</view>
</view>
</view>
<!-- 取消引用 -->
<view v-if="isReplyValid" class="reply-seam" @touchstart.prevent="clearReplyRef">
<view class="reply-seam-line" />
<text class="reply-seam-text">取消引用</text>
</view>
<view
class="input-container"
:class="{
'has-reply': isReplyValid,
'mode-longpress': isLongPressing,
'mode-cancelling': isLongPressing && isCancelling
}"
:style="{
'--dynamic-radius': dynamicRadius + 'rpx',
height: containerHeightRpx + 'rpx'
}"
@tap.stop
>
<!-- 左侧图标 -->
<view
class="toggle-wrapper"
:class="{ 'node-hidden': isLongPressing, 'btn-press': leftBtnPressed }"
v-if="showLeftBtn"
@touchstart="leftBtnPressed = true"
@touchend="leftBtnPressed = false"
@touchcancel="leftBtnPressed = false"
@tap="LeftButtonHandle"
>
<image class="toggle-icon" :src="LeftIcon" />
</view>
<!-- 中间触发区域 -->
<view
class="input-area"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
@touchcancel="handleTouchEnd"
>
<textarea
class="input-text"
:class="{ 'input-hidden': isLongPressing, 'is-scrollable': !shouldAutoHeight }"
v-model="localInputText"
:adjust-position="false"
:hold-keyboard="holdKeyboard"
:focus="inputFocus"
:confirm-type="confirmType"
:auto-height="shouldAutoHeight"
:fixed="true"
:maxlength="-1"
:style="{
fontSize: textareaFontSize + 'rpx',
...(!shouldAutoHeight ? { height: MAX_TEXTAREA_HEIGHT + 'rpx' } : {})
}"
@focus="onFocus"
@blur="onBlur"
@confirm="handleConfirm"
@linechange="onLineChange"
:placeholder="placeholder"
placeholder-class="input-placeholder"
/>
<!-- 长按激活时的遮罩/提示 -->
<view v-if="isLongPressing" class="longpress-overlay">
<text class="longpress-tip">
{{ isCancelling ? cancelTip : longPressTip }}
</text>
</view>
</view>
<!-- 右侧按钮区域 -->
<view
class="attachment-btn"
:class="{ 'is-send': hasText, 'node-hidden': isLongPressing, 'btn-press': rightBtnPressed }"
@touchstart.prevent="rightBtnPressed = true; handleRightBtnTap()"
@touchend="rightBtnPressed = false"
@touchcancel="rightBtnPressed = false"
>
<image class="icon send-icon" :src="sendIcon"></image>
<image class="icon attachment-icon" :src="attachmentIcon"></image>
</view>
</view>
</view>
<!-- 悬浮面板 -->
<floatpanel
:visible="innerShowPanel"
:buttons="buttons"
:height="panelHeight"
:bottomOffset="panelBottomOffset"
:horizontalPadding="panelPadding"
@handleTap="onPanelSelect"
>
<template v-if="hasCustomPanel" #default>
<slot name="panel-content"></slot>
</template>
</floatpanel>
<!-- 遮罩层点击空白处关闭面板 -->
<view
v-if="innerShowPanel"
class="panel-mask"
@tap="setPanelVisible(false)"
/>
</view>
</template>
<script setup>
import { ref, computed, nextTick, watch, useSlots , onMounted , onUnmounted } from 'vue'
import floatpanel from './floatPanel.vue'
const slots = useSlots()
const props = defineProps({
// --- 内容与状态 ---
modelValue: { type: String, default: '' }, // 支持 v-model 直接绑定文字内容
showPanel: { type: Boolean, default: false }, // 支持 v-model:showPanel
/** 引用回复:外部传入任意对象,内部仅要求包含 content(string) 与 Name/name有效时才显示引用条支持双向 */
replyRef: { type: [Object, null], default: null },
/** 外部控制的焦点状态 */
focus: { type: Boolean, default: false },
// --- 提示文字 ---
placeholder: { type: String, default: '点击输入或按住说话....' },
longPressTip: { type: String, default: '正在录音,上滑取消' },
cancelTip: { type: String, default: '松开手指,取消发送' },
// --- 样式配置 ---
themeColor: { type: String, default: '#a846e6' },
barHeight: { type: String, default: '80rpx' },
padBottom: { type: String, default: '4rpx' },
LeftIcon: { type: String, default: '/static/Mic.png' },
sendIcon: { type: String, default: '/static/send.png' },
attachmentIcon: { type: String, default: '/static/Attachment.png' },
// --- 功能开关 ---
showLeftBtn: { type: Boolean, default: true }, // 是否显示左侧图标
// --- 面板配置 ---
buttons: { type: Array },
panelHeight: { type: String, default: '100rpx' },
panelBottomOffset: { type: String, default: '10rpx' },
panelPadding: { type: String, default: '15rpx' },
// --- 交互参数 ---
longPressDelay: { type: Number, default: 200}, // 长按判定时间(ms) -- 这个数值不建议高于500 高于会触发长按聚焦文字框
cancelThreshold: { type: Number, default: 100 }, // 上滑取消判定距离(px)
// --- 原生 Input 属性 ---
adjustPosition: { type: Boolean, default: false },
holdKeyboard: { type: Boolean, default: true },
confirmType: { type: String, default: 'send' }
})
const emit = defineEmits([
'update:modelValue',
'update:showPanel',
'update:replyRef',
'longPressStart',
'longPressEnd',
'longPressCancel',
'send',
'panelClick',
'leftButtonClick',
'focus',
'blur',
'keyboardHeightChange'
])
// --- 内部变量 ---
const inputFocus = ref(false)
const isLongPressing = ref(false)
const isCancelling = ref(false)
const startY = ref(0)
const lastMoveY = ref(0) // 记录最后一次移动的 Y 坐标
const longPressTimer = ref(null)
const focusLock = ref(false)
const didTouchMove = ref(false) // 是否发生了滑动(用于区分点击聚焦 / 滑动松手)
const leftBtnPressed = ref(false)
const rightBtnPressed = ref(false)
const keyboardHeight = ref(0)
// 监听外部传入的 focus prop立即同步以提高响应速度
watch(() => props.focus, (val) => {
if (val !== inputFocus.value) {
inputFocus.value = val
// 如果外部设置为 false触发 blur 事件
if (!val) {
emit('blur')
}
}
}, { immediate: true })
// 双向绑定输入框内容
const localInputText = ref(props.modelValue)
watch(() => props.modelValue, (val) => localInputText.value = val)
watch(localInputText, (val) => emit('update:modelValue', val))
// 双向绑定面板显示状态
const innerShowPanel = ref(props.showPanel)
watch(() => props.showPanel, (val) => innerShowPanel.value = val)
const setPanelVisible = (visible) => {
innerShowPanel.value = visible
emit('update:showPanel', visible)
}
// 引用回复:仅当包含 content(string) 与 Name/name 时视为有效
const isReplyValid = computed(() => {
const r = props.replyRef
if (!r || typeof r !== 'object') return false
const hasContent = typeof r.content === 'string'
const hasName = typeof (r.Name ?? r.name) === 'string'
return hasContent && hasName
})
const replyDisplayName = computed(() => {
if (!isReplyValid.value) return ''
const r = props.replyRef
return (r.Name ?? r.name) ?? ''
})
const replyDisplayContent = computed(() => {
if (!isReplyValid.value) return ''
const raw = props.replyRef?.content ?? ''
const oneLine = String(raw).replace(/\s*[\r\n]+\s*/g, ' ').trim()
return oneLine.length > 40 ? oneLine.slice(0, 40) + '…' : oneLine
})
const clearReplyRef = () => {
emit('update:replyRef', null)
}
// 逻辑计算
const hasCustomPanel = computed(() => !!slots['panel-content'])
const hasText = computed(() => localInputText.value.trim().length > 0)
// 输入区高度(rpx)
const textareaHeightRpx = ref(68)
// 从 barHeight prop 解析出数值(去掉 'rpx' 后缀)
const PILL_HEIGHT = computed(() => {
const heightStr = props.barHeight.replace('rpx', '')
return parseInt(heightStr) || 80
})
// 字体大小跟随 barHeight 等比例缩放(默认 80rpx 对应 28rpx
const textareaFontSize = computed(() => {
const baseHeight = 80
const baseFontSize = 28
const ratio = PILL_HEIGHT.value / baseHeight
return Math.round(baseFontSize * ratio)
})
const MAX_TEXTAREA_HEIGHT = 300 // textarea 超过此高度时变为可滚动
const MAX_HEIGHT = 16 + MAX_TEXTAREA_HEIGHT // 容器最大高度 = 内边距 + textarea高度
const PILL_RADIUS = 50
const RECT_RADIUS = 36
// 当高度超过阈值时 - 输入区域变成可滚动
const shouldAutoHeight = computed(() => textareaHeightRpx.value < MAX_TEXTAREA_HEIGHT)
// 容器高度rpx
const containerHeightRpx = computed(() => {
// 最小高度 = barHeight - padding (上下各6rpx = 12rpx)
const minInnerH = PILL_HEIGHT.value - 12
const innerH = Math.max(minInnerH, textareaHeightRpx.value)
const h = 12 + innerH // 上下 padding 各 6rpx
return Math.min(MAX_HEIGHT, Math.max(PILL_HEIGHT.value, h))
})
const dynamicRadius = computed(() => {
const containerH = containerHeightRpx.value
const pillH = PILL_HEIGHT.value
if (containerH <= pillH) return PILL_RADIUS
if (containerH >= MAX_HEIGHT) return RECT_RADIUS
return Math.round(PILL_RADIUS - (containerH - pillH) * (PILL_RADIUS - RECT_RADIUS) / (MAX_HEIGHT - pillH))
})
const dynamicBottom = computed(() => {
if (keyboardHeight.value > 0) {
// 键盘弹出时:直接使用键盘的物理像素高度 + 你自己的下边距
return `calc(${keyboardHeight.value}px + ${props.padBottom})`
} else {
// 键盘收起时:回归底部,兼容全面屏小黑条
return `calc(env(safe-area-inset-bottom) + ${props.padBottom})`
}
})
const onLineChange = (e) => {
let { height, heightRpx } = e.detail || {}
// 部分平台只有 height(px),需转换为 rpx
if ((!heightRpx || heightRpx <= 0) && height > 0) {
const winWidth = uni.getSystemInfoSync().windowWidth || 375
heightRpx = Math.round(height * 750 / winWidth)
}
// if (heightRpx > 0) textareaHeightRpx.value = heightRpx
if (heightRpx > 0) {
textareaHeightRpx.value = heightRpx;
// 触发高度变化事件,通知父组件滚动列表
nextTick(() => {
emit('heightChange');
});
}
}
// 清空时重置高度
watch(localInputText, (val) => {
if (!val.trim()) {
// 重置为 barHeight - padding (上下各6rpx = 12rpx)
textareaHeightRpx.value = PILL_HEIGHT.value - 12
}
})
// --- 事件处理 ---
const handleTouchStart = (e) => {
didTouchMove.value = false
if (focusLock.value || hasText.value || inputFocus.value) return
const touchY = e.touches[0].clientY
startY.value = touchY
lastMoveY.value = touchY // 初始化为起始位置
longPressTimer.value = setTimeout(() => {
isLongPressing.value = true
isCancelling.value = false
uni.vibrateShort();
emit('longPressStart');
}, props.longPressDelay)
}
const handleTouchMove = (e) => {
if (!isLongPressing.value) {
didTouchMove.value = true // 发生滑动touchend 时不要触发聚焦
return // 不拦截,让 textarea 能正常滚动
}
e.preventDefault()
const moveY = e.touches[0].clientY
lastMoveY.value = moveY
isCancelling.value = (startY.value - moveY > props.cancelThreshold)
}
const handleTouchEnd = () => {
if (longPressTimer.value) {
clearTimeout(longPressTimer.value)
longPressTimer.value = null
}
if (isLongPressing.value) {
const shouldCancel = (startY.value - lastMoveY.value > props.cancelThreshold)
emit(shouldCancel ? 'longPressCancel' : 'longPressEnd')
isLongPressing.value = false
isCancelling.value = false
focusLock.value = true
setTimeout(() => { focusLock.value = false }, 200) // 减少锁定时间,提高响应速度
} else {
// 只有真正的点击(无滑动)才触发聚焦,滑动松手不触发
// 优化:如果外部已经设置了 focus或者没有滑动则触发聚焦
if (!focusLock.value && !didTouchMove.value) {
// 如果外部传入的 focus 为 true直接同步内部状态
if (props.focus) {
inputFocus.value = true
} else if (!inputFocus.value) {
// 只有在未聚焦时才触发聚焦
nextTick(() => {
inputFocus.value = true
emit('focus')
})
}
}
// 重置滑动标志
didTouchMove.value = false
}
}
const onFocus = () => {
inputFocus.value = true
emit('focus');
}
const onBlur = () => {
inputFocus.value = false
emit('blur'); // 触发 blur 事件,让父组件知道焦点已失去
}
const handleRightBtnTap = () => {
if (hasText.value) {
emit('send', { type: 'text', content: localInputText.value })
localInputText.value = ''
} else {
setPanelVisible(!innerShowPanel.value)
}
}
// 发送键
const handleConfirm = () => {
if (hasText.value) {
emit('send', { type: 'text', content: localInputText.value })
localInputText.value = ''
}
}
const onPanelSelect = (item) => {
emit('panelClick', item.button)//返回对应的button数据
setTimeout(() => setPanelVisible(false), 150)
}
const LeftButtonHandle = () => {
emit('leftButtonClick');
}
onMounted(() => {
uni.onKeyboardHeightChange(res => {
keyboardHeight.value = res.height;
emit('keyboardHeightChange', res.height);
});
});
onUnmounted(() => {
uni.offKeyboardHeightChange();
});
</script>
<style lang="scss" scoped>
$paddingBottom : var(--paddingBottom);
$MainColor: var(--main-color);
.input-wrapper {
width: 100%;
box-sizing: border-box;
position: relative;
flex-shrink: 0;
z-index: 999;
display: flex;
flex-direction: column;
align-items: center;
padding: 0 12rpx;
transition: padding-bottom 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
height 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
/* 引用条 + 输入框的纵向容器 */
.input-bar-group {
display: flex;
flex-direction: column;
width: 100%;
align-items: stretch;
}
/* 引用回复条*/
.reply-bar {
width: 100%;
box-sizing: border-box;
background-color: rgba(38, 38, 38, 0.6);
backdrop-filter: blur(20rpx);
-webkit-backdrop-filter: blur(20rpx);
border: 1px solid rgba(255, 255, 255, 0.05);
border-bottom: none;
border-radius: var(--dynamic-radius, 50rpx) var(--dynamic-radius, 50rpx) 0 0;
margin-bottom: -1rpx;
padding: 8rpx 10rpx 10rpx;
}
.reply-bar-inner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2rpx;
}
.reply-text-wrap {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2rpx;
}
/* Name */
.reply-name {
font-size: 20rpx;
font-weight: 500;
color: rgba(255, 255, 255, 0.42);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
text-align: center;
}
/* Content */
.reply-content {
font-size: 28rpx;
font-weight: 500;
color: rgba(255, 255, 255, 0.88);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
text-align: center;
max-width: 100%;
}
/* 引用内容渐变线 */
.reply-seam-line {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 1px;
background: linear-gradient(
to right,
transparent 0%,
transparent 28%,
rgba(255, 255, 255, 0.2) 48%,
rgba(255, 255, 255, 0.2) 52%,
transparent 72%,
transparent 100%
);
}
/* 接缝 + 「取消引用」文字 */
.reply-seam {
position: relative;
width: 100%;
box-sizing: border-box;
background-color: rgba(38, 38, 38, 0.6);
backdrop-filter: blur(20rpx);
-webkit-backdrop-filter: blur(20rpx);
border-left: 1px solid rgba(255, 255, 255, 0.05);
border-right: 1px solid rgba(255, 255, 255, 0.05);
padding: 5rpx 10rpx 8rpx;
display: flex;
align-items: center;
justify-content: center;
}
.reply-seam-text {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.4);
}
.input-container {
display: flex;
box-sizing: border-box;
align-items: center;
width: 100%;
min-height: var(--bar-height);
max-height: 360rpx;
background-color: rgba(38, 38, 38, 0.6);
backdrop-filter: blur(20rpx);
-webkit-backdrop-filter: blur(20rpx);
border-radius: var(--dynamic-radius, 50rpx);
padding-top: 6rpx;
padding-bottom: 6rpx;
padding-left: 6rpx;
padding-right: 6rpx;
border: 1px solid rgba(255, 255, 255, 0.05);
/* 高度、圆角、背景色 带缓动过渡 */
transition: height 0.28s cubic-bezier(0.25, 0.46, 0.45, 0.94),
border-radius 0.28s cubic-bezier(0.25, 0.46, 0.45, 0.94),
background-color 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
backdrop-filter 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
overflow: hidden;
&.has-reply {
border-radius: 0 0 var(--dynamic-radius, 36rpx) var(--dynamic-radius, 36rpx);
}
&.mode-longpress {
background-color: var(--main-color) !important;
backdrop-filter: blur(20rpx);
-webkit-backdrop-filter: blur(20rpx);
}
&.mode-cancelling {
background-color: rgba(255, 77, 79, 0.8) !important;
backdrop-filter: blur(20rpx);
-webkit-backdrop-filter: blur(20rpx);
}
}
/* 长按时两侧按钮的平滑缩小/隐藏(带过渡动效) */
.node-hidden {
width: 0 !important;
height: 0 !important;
opacity: 0 !important;
margin: 0 !important;
padding: 0 !important;
transform: scale(0);
pointer-events: none;
}
.toggle-wrapper {
height: calc(var(--bar-height) - 12rpx);
width: calc(var(--bar-height) - 12rpx);
aspect-ratio: 1;
border-radius: 999rpx;
background-color: $MainColor;
display: flex;
justify-content: center;
align-items: center;
flex-shrink: 0;
/* 按下缩放 + 长按隐藏时的缩小动效 */
transition: width 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
height 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
opacity 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
margin 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
padding 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
transform 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
&.btn-press {
transform: scale(0.92);
}
.toggle-icon {
height: 70%;
width: 70%;
}
}
.input-area {
flex: 1;
margin: 0 8rpx;
display: flex;
align-items: center;
position: relative;
overflow: visible;
min-width: 0;
min-height: calc(var(--bar-height) - 12rpx);
max-height: 300rpx;
.input-placeholder {
color: #999999;
font-weight: 400;
}
.input-text {
width: 100%;
font-weight: 800;
color: #ffffff;
transition: opacity 0.2s;
line-height: 1.3;
box-sizing: border-box;
padding: 0;
&.input-hidden {
opacity: 0;
visibility: hidden;
}
/* 超过最大高度后变为可滚动 */
&.is-scrollable {
overflow-y: auto !important;
}
}
}
.longpress-overlay {
position: absolute;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
pointer-events: none;
.longpress-tip {
font-size: 30rpx;
color: #ffffff;
font-weight: bold;
}
}
.attachment-btn {
--offset: 6%;
width: calc(var(--bar-height) - 12rpx);
height: calc(var(--bar-height) - 12rpx);
border-radius: 999rpx;
background-color: rgba(255, 255, 255, 0.08);
display: flex;
justify-content: center;
align-items: center;
position: relative;
overflow: hidden;
flex-shrink: 0;
/* 按下缩放 + 长按隐藏时的缩小动效 + 主题色渐变 */
transition: width 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
height 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
opacity 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
margin 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
padding 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
transform 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
background-color 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
filter 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
&.btn-press {
transform: scale(0.92);
}
.icon {
position: absolute;
width: 50%;
height: 50%;
transition: all 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
.attachment-icon {
opacity: 1;
transform: rotate(0deg) scale(1);
}
.send-icon {
opacity: 0;
transform: rotate(-90deg) scale(0) translateX(var(--offset));
}
&.is-send {
background-color: $MainColor;
filter: brightness(1.1); // 相对主要色变得亮一点
.attachment-icon {
opacity: 0;
transform: rotate(90deg) scale(0);
}
.send-icon {
opacity: 1;
transform: rotate(0deg) scale(1) translateX(var(--offset));
}
}
}
.panel-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 998;
}
</style>