742 lines
21 KiB
Vue
742 lines
21 KiB
Vue
|
|
<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>
|