vue-atuser
Vue@某人,At某人,仿新浪微博@某人,@user
vue-edit
Vue实现渲染数据后控制滚动条位置 📜
Element.scrollIntoView()
Element.scrollIntoView() // 如果为false,元素的底端将和其所在滚动区的可视区域的底端对齐。
Web聊天工具的富文本输入框
div+contenteditable 实现富文本发布框的小结
实现高度“听话”的多行文本输入框
原生js 实现输入框emoji表情发布
获取光标位置,设置光标位置
Vue实现字符串中自定义标识符的解析渲染 🎩
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | /** * 获取光标位置 * @param {DOMElement} element 输入框的dom节点 * @return {Number} 光标位置 */ export const getCursorPosition = (element) => { let caretOffset = 0 const doc = element.ownerDocument || element.document const win = doc.defaultView || doc.parentWindow const sel = win.getSelection() if (sel.rangeCount > 0) { const range = win.getSelection().getRangeAt(0) const preCaretRange = range.cloneRange() preCaretRange.selectNodeContents(element) preCaretRange.setEnd(range.endContainer, range.endOffset) caretOffset = preCaretRange.toString().length } return caretOffset } /** * 设置光标位置 * @param {DOMElement} element 输入框的dom节点 * @param {Number} cursorPosition 光标位置的值 */ export const setCursorPosition = (element, cursorPosition) => { const range = document.createRange() range.setStart(element.firstChild, cursorPosition) range.setEnd(element.firstChild, cursorPosition) const sel = window.getSelection() sel.removeAllRanges() sel.addRange(range) } |
Vue实现图片与文字混输 🔥
DEMO
atuser.vue
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389<template>
<div ref=
"wrap"
class
=
"atwho-wrap"
@input=
"handleInput"
@keydown=
"handleKeyDown"
>
<div v-
if
=
"atwho"
class
=
"atwho-panel"
:style=
"style"
>
<ul
class
=
"atwho-view atwho-ul"
>
<li v-
for
=
"(item, index) in atwho.list"
class
=
"atwho-li"
:key=
"index"
:
class
=
"isCur(index) && 'atwho-cur'"
:ref=
"isCur(index) && 'cur'"
:data-index=
"index"
@mouseenter=
"handleItemHover"
@click=
"handleItemClick"
>
<span v-text=
"item"
></span>
</li>
<li>
<span>展开更多群成员</span>
<img src=
"data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBzdGFuZGFsb25lPSJubyI/PjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+PHN2ZyB0PSIxNTQ1NDgyNjY3NzY4IiBjbGFzcz0iaWNvbiIgc3R5bGU9IiIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHAtaWQ9IjEwODYiIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB3aWR0aD0iMTYiIGhlaWdodD0iMTYiPjxkZWZzPjxzdHlsZSB0eXBlPSJ0ZXh0L2NzcyI+PC9zdHlsZT48L2RlZnM+PHBhdGggZD0iTTMzNi43MzMgMTE5LjY2N2wtNTYuMjc4IDU1LjcyIDMzNC44NTcgMzM3LjE1NC0zMzcuNjczIDMzNC4zMTUgNTUuODAyIDU2LjE4NCAzOTMuOTQ0LTM5MC4wNDB6IiBwLWlkPSIxMDg3Ij48L3BhdGg+PC9zdmc+"
/>
</li>
</ul>
</div>
<slot></slot>
</div>
</template>
<script>
import
getCaretCoordinates from
'textarea-caret'
export
default
{
props: {
value: {
//输入框初始值
type: String,
default
:
null
},
suffix: {
//插入字符链接
type: String,
default
:
' '
},
loop: {
//上下箭头循环
type: Boolean,
default
:
true
},
avoidEmail: {
//@前不能是字符
type: Boolean,
default
:
true
},
hoverSelect: {
//悬浮选中
type: Boolean,
default
:
true
},
members: {
//选择框选项列表
type: Array,
default
: () => []
},
nameKey: {
type: String,
default
:
''
}
},
data() {
return
{
atItems: [
'@'
],
bindsValue:
this
.value !=
null
,
atwho:
null
}
},
computed: {
style() {
if
(
this
.atwho) {
const {
list,
cur,
x,
y
} =
this
.atwho
const {
wrap
} =
this
.$refs
const el =
this
.$el.querySelector(
'textarea'
)
if
(wrap) {
const left = x + el.offsetLeft - el.scrollLeft +
'px'
const top = y + el.offsetTop - el.scrollTop + 25 +
'px'
return
{
left,
top
}
}
}
return
null
}
},
watch: {
members() {
this
.handleInput(
true
)
},
value(value, oldValue) {
if
(
this
.bindsValue) {
this
.handleValueUpdate(value)
}
}
},
mounted() {
if
(
this
.bindsValue) {
this
.handleValueUpdate(
this
.value)
}
},
methods: {
getAtAndIndex(text, ats) {
return
ats.map((at) => {
return
{
at,
index: text.lastIndexOf(at)
}
}).reduce((a, b) => {
return
a.index > b.index ? a : b
})
},
isCur(index) {
return
index ===
this
.atwho.cur
},
handleValueUpdate(value) {
//更新textarea的值
const el =
this
.$el.querySelector(
'textarea'
)
if
(value !== el.value) {
el.value = value
}
},
handleItemHover(e) {
if
(
this
.hoverSelect) {
this
.selectByMouse(e)
}
},
handleItemClick(e) {
this
.selectByMouse(e)
this
.insertItem()
},
handleKeyDown(e) {
const {
atwho
} =
this
if
(atwho) {
if
(e.keyCode === 38 || e.keyCode === 40) {
// ↑/↓
if
(!(e.metaKey || e.ctrlKey)) {
e.preventDefault()
e.stopPropagation()
this
.selectByKeyboard(e)
}
return
}
if
(e.keyCode === 13) {
// enter
this
.insertItem()
e.preventDefault()
e.stopPropagation()
return
}
if
(e.keyCode === 27) {
// esc
this
.closePanel()
return
}
}
// 为了兼容ie ie9~11 editable无input事件 只能靠keydown触发 textarea正常
// 另 ie9 textarea的delete不触发input
const isValid = e.keyCode >= 48 && e.keyCode <= 90 || e.keyCode === 8
if
(isValid) {
setTimeout(() => {
this
.handleInput()
}, 50)
}
if
(e.keyCode === 8) {
//删除
//this.handleDelete(e)
}
if
(e.keyCode === 13) {
//删除
this
.$emit(
"enterSend"
,e)
}
},
handleInput(event) {
const el =
this
.$el.querySelector(
'textarea'
)
this
.$emit(
'input'
, el.value)
//更新父组件
const text = el.value.slice(0, el.selectionEnd)
if
(text) {
const {
atItems,
avoidEmail
} =
this
let
show =
true
const {
at,
index
} =
this
.getAtAndIndex(text, atItems)
if
(index < 0) show =
false
const prev = text[index - 1]
//上一个字符
const chunk = text.slice(index + at.length, text.length)
if
(avoidEmail) {
//上一个字符不能为字母数字 避免与邮箱冲突,微信则是避免 所有字母数字及半角符号
if
(/^[a-z0-9]$/i.test(prev)) show =
false
}
if
(/^\s/.test(chunk)) show =
false
//chunk以空白字符开头不匹配 避免`@ `也匹配
if
(!show) {
this
.closePanel()
}
else
{
const {
members,
filterMatch
} =
this
if
(!event) {
// fixme: should be consistent with At.vue
this
.$emit(
'at'
, chunk)
}
const matched = members.filter(v => {
return
v.toString().indexOf(chunk) > -1
})
if
(matched.length) {
this
.openPanel(matched, chunk, index, at)
}
else
{
this
.closePanel()
}
}
}
else
{
this
.closePanel()
}
},
closePanel() {
if
(
this
.atwho) {
this
.atwho =
null
}
},
openPanel(list, chunk, offset, at) {
//打开Atuser列表 matched, chunk, index, at 过滤数组,匹配项,匹配项index,'@'
const fn = () => {
const el =
this
.$el.querySelector(
'textarea'
)
const atEnd = offset + at.length
// 从@后第一位开始
const rect = getCaretCoordinates(el, atEnd)
this
.atwho = {
chunk,
offset,
list,
atEnd,
x: rect.left,
y: rect.top - 4,
cur: 0,
// todo: 尽可能记录
}
}
if
(
this
.atwho) {
fn()
}
else
{
// 焦点超出了显示区域 需要提供延时以移动指针 再计算位置
setTimeout(fn, 10)
}
},
selectByMouse(e) {
function
closest(el, predicate) {
//遍历直到有data-index为止
do
{
if
(predicate(el))
return
el;
}
while
(el = el && el.parentNode);
}
const el = closest(e.target, d => {
return
d.getAttribute(
'data-index'
)
})
const cur = +el.getAttribute(
'data-index'
)
this
.atwho = {
...
this
.atwho,
cur
}
},
selectByKeyboard(e) {
const offset = e.keyCode === 38 ? -1 : 1
const {
cur,
list
} =
this
.atwho
const nextCur =
this
.loop ?
(cur + offset + list.length) % list.length :
Math.max(0, Math.min(cur + offset, list.length - 1))
this
.atwho = {
...
this
.atwho,
cur: nextCur
}
},
// todo: 抽离成库并测试
insertText(text, el) {
const start = el.selectionStart
const end = el.selectionEnd
el.value = el.value.slice(0, start) +
text + el.value.slice(end)
const newEnd = start + text.length
el.selectionStart = newEnd
el.selectionEnd = newEnd
},
insertItem() {
const {
chunk,
offset,
list,
cur,
atEnd
} =
this
.atwho
const {
suffix,
atItems
} =
this
const el =
this
.$el.querySelector(
'textarea'
)
const text = el.value.slice(0, atEnd)
const {
at,
index
} =
this
.getAtAndIndex(text, atItems)
const start = index + at.length
// 从@后第一位开始
el.selectionStart = start
el.focus()
// textarea必须focus回来
const curItem = list[cur]
const t =
''
+ curItem + suffix
this
.insertText(t, el)
this
.$emit(
'insert'
, curItem)
//插入字符
this
.handleInput()
}
}
}
</script>
<style lang=
"less"
scoped=
"scoped"
>
.atwho-wrap {
width: 100%;
font-size: 12px;
color:
#333;
position: relative;
.atwho-panel {
position: absolute;
&.test {
width: 2px;
height: 2px;
background: red;
}
.atwho-inner {
position: relative;
}
}
.atwho-view {
color: black;
z-index: 11110 !important;
border-radius: 2px;
box-shadow: 0 0 10px 0 rgba(101, 111, 122, .5);
position: absolute;
cursor: pointer;
background-color: rgba(255, 255, 255, .94);
width: 170px;
max-height: 312px;
&::-webkit-scrollbar {
width: 11px;
height: 11px;
}
&::-webkit-scrollbar-track {
background-color:
#F5F5F5;
}
&::-webkit-scrollbar-thumb {
min-height: 36px;
border: 2px solid transparent;
border-top: 3px solid transparent;
border-bottom: 3px solid transparent;
background-clip: padding-box;
border-radius: 7px;
background-color:
#C4C4C4;
}
}
.atwho-ul {
list-style: none;
padding: 0;
margin: 0;
li {
box-sizing: border-box;
display: block;
height: 25px;
padding: 2px 10px;
white-space: nowrap;
display: flex;
align-items: center;
justify-content: space-between;
&.atwho-cur {
background:
#f2f2f5;
color:
#eb7350;
}
span {
overflow: hidden;
text-overflow: ellipsis;
}
img {
height: 13px;
width: 13px;
}
}
}
}
</style>
index.vue
123456789101112131415161718192021222324252627282930313233343536373839404142434445<template>
<div
class
=
"atuser"
>
<at :members=
"members"
@enterSend=
"send"
v-model=
"inputcontent"
>
<textarea
class
=
"editor"
></textarea>
</at>
</div>
</template>
<script>
import
at from
'./atuser.vue'
export
default
{
data() {
return
{
members: [123, 12, 1234, 12345,
"小花"
,
"小花华"
,
"小三"
],
inputcontent:
""
//用户输入内容初始值
};
},
components: {
at,
},
methods: {
send(e) {
//回车发送
console.log(e)
}
}
}
</script>
<style scoped=
"scoped"
lang=
"less"
>
.atuser {
width: 700px;
height: 160px;
border: 1px solid red;
.editor{
width: 700px;
height: 160px;
overflow: hidden;
border: 0px;
outline: none;
resize: none;
-webkit-appearance: none;
}
}
</style>
参考: https://www.cnblogs.com/ygunoil/p/13503648.html
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· AI与.NET技术实操系列:基于图像分类模型对图像进行分类
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 25岁的心里话
· 闲置电脑爆改个人服务器(超详细) #公网映射 #Vmware虚拟网络编辑器
· 零经验选手,Compose 一天开发一款小游戏!
· 通过 API 将Deepseek响应流式内容输出到前端
· AI Agent开发,如何调用三方的API Function,是通过提示词来发起调用的吗