feat: textarea高亮词语组件调整

This commit is contained in:
rd
2025-08-13 16:33:01 +08:00
parent d4d9d8f82c
commit 930051a39c
2 changed files with 98 additions and 54 deletions

View File

@ -1,24 +1,11 @@
<template>
<div class="highlight-textarea-container">
<a-textarea
v-model="inputValue"
placeholder="请输入作品描述"
:disabled="disabled"
show-word-limit
:max-length="1000"
size="large"
class="textarea-input h-full w-full"
@input="handleInput"
@focus="() => (focus = true)"
@blur="() => (focus = false)"
/>
<a-textarea ref="textareaWrapRef" v-model="inputValue" placeholder="请输入作品描述" :disabled="disabled" show-word-limit
:max-length="1000" size="large" class="textarea-input h-full w-full" @input="handleInput"
@focus="() => (focus = true)" @blur="() => (focus = false)" />
<div
class="textarea-highlight"
:class="{ focus: focus }"
:style="{ visibility: inputValue ? 'visible' : 'hidden' }"
v-html="highlightedHtml"
/>
<div class="textarea-highlight" :class="{ focus: focus }" :style="{ visibility: inputValue ? 'visible' : 'hidden' }"
v-html="highlightedHtml" />
</div>
</template>
@ -47,9 +34,11 @@ const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
}>();
// 内部状态管理
const inputValue = ref(props.modelValue || '');
const scrollTop = ref(0);
const focus = ref(false);
const textareaWrapRef = ref();
let nativeTextarea: HTMLTextAreaElement | null = null;
const highlightedHtml = computed(() => generateHighlightedHtml());
// 监听外部modelValue变化
@ -67,12 +56,6 @@ const handleInput = (value: string) => {
emit('update:modelValue', value);
};
// 同步滚动位置
const syncScroll = (e: Event) => {
console.log('syncScroll');
scrollTop.value = (e.target as HTMLTextAreaElement).scrollTop;
};
const escapeHtml = (str: string): string => {
if (!isString(str)) return '';
return str
@ -114,11 +97,24 @@ const generateHighlightedHtml = (): string => {
};
onMounted(() => {
document.querySelector('.textarea-input .arco-textarea')?.addEventListener('scroll', handleTextareaScroll);
nativeTextarea = (textareaWrapRef.value?.$el || textareaWrapRef.value)?.querySelector?.('textarea.arco-textarea') ||
document.querySelector('.textarea-input .arco-textarea');
if (nativeTextarea) {
nativeTextarea.addEventListener('scroll', handleTextareaScroll);
nativeTextarea.addEventListener('compositionstart', handleCompositionUpdate);
nativeTextarea.addEventListener('compositionupdate', handleCompositionUpdate);
nativeTextarea.addEventListener('compositionend', handleCompositionUpdate);
}
});
onUnmounted(() => {
document.querySelector('.textarea-input .arco-textarea')?.removeEventListener('scroll', handleTextareaScroll);
if (nativeTextarea) {
nativeTextarea.removeEventListener('scroll', handleTextareaScroll);
nativeTextarea.removeEventListener('compositionstart', handleCompositionUpdate);
nativeTextarea.removeEventListener('compositionupdate', handleCompositionUpdate);
nativeTextarea.removeEventListener('compositionend', handleCompositionUpdate);
}
});
const handleTextareaScroll = (e: Event) => {
@ -129,6 +125,18 @@ const handleTextareaScroll = (e: Event) => {
highlightElement.scrollTop = _scrollTop;
}
};
const handleCompositionUpdate = () => {
if (!nativeTextarea) return;
// 使用 rAF 等待浏览器把最新字符写入 textarea.value 再读取
requestAnimationFrame(() => {
if (!nativeTextarea) return;
const latest = nativeTextarea.value.slice(0, 1000);
if (latest !== inputValue.value) {
inputValue.value = latest;
}
});
};
</script>
<style scoped lang="scss">
@ -136,9 +144,11 @@ const handleTextareaScroll = (e: Event) => {
position: relative;
width: 100%;
height: 100%;
@mixin textarea-padding {
padding: 8px 12px;
}
@mixin textarea-style {
box-sizing: border-box;
width: 100%;
@ -153,6 +163,7 @@ const handleTextareaScroll = (e: Event) => {
word-wrap: break-word;
z-index: inherit;
}
.textarea-highlight {
@include textarea-style;
position: absolute;
@ -163,13 +174,16 @@ const handleTextareaScroll = (e: Event) => {
background: #fff;
overflow: hidden;
@include textarea-padding;
&.focus {
border-color: rgb(var(--primary-6)) !important;
box-shadow: 0 0 0 0 var(--color-primary-light-2) !important;
}
}
:deep(.arco-textarea-wrapper) {
@include textarea-style;
.arco-textarea {
padding: 0;
overflow: hidden;
@ -181,17 +195,25 @@ const handleTextareaScroll = (e: Event) => {
caret-color: #211f24 !important;
@include textarea-padding;
// -webkit-text-fill-color: transparent !important;
// -webkit-text-fill-color: transparent !important;
overflow-y: auto;
}
.arco-textarea-word-limit {
z-index: 2;
}
&.arco-textarea-disabled {
.arco-textarea {
background: #f2f3f5;
}
}
}
// 处于中文输入法合成态时,显示真实文本并隐藏高亮层
:deep(.textarea-input.composing .arco-textarea) {
color: #211f24 !important;
-webkit-text-fill-color: #211f24 !important;
}
}
</style>

View File

@ -1,24 +1,11 @@
<template>
<div class="highlight-textarea-container">
<a-textarea
v-model="inputValue"
placeholder="请输入作品描述"
:disabled="disabled"
show-word-limit
:max-length="1000"
size="large"
class="textarea-input h-full w-full"
@input="handleInput"
@focus="() => (focus = true)"
@blur="() => (focus = false)"
/>
<a-textarea ref="textareaWrapRef" v-model="inputValue" placeholder="请输入作品描述" :disabled="disabled" show-word-limit
:max-length="1000" size="large" class="textarea-input h-full w-full" @input="handleInput"
@focus="() => (focus = true)" @blur="() => (focus = false)" />
<div
class="textarea-highlight"
:class="{ focus: focus }"
:style="{ visibility: inputValue ? 'visible' : 'hidden' }"
v-html="highlightedHtml"
/>
<div class="textarea-highlight" :class="{ focus: focus }" :style="{ visibility: inputValue ? 'visible' : 'hidden' }"
v-html="highlightedHtml" />
</div>
</template>
@ -51,6 +38,8 @@ const emit = defineEmits<{
const inputValue = ref(props.modelValue || '');
const scrollTop = ref(0);
const focus = ref(false);
const textareaWrapRef = ref();
let nativeTextarea: HTMLTextAreaElement | null = null;
const highlightedHtml = computed(() => generateHighlightedHtml());
// 监听外部modelValue变化
@ -68,12 +57,6 @@ const handleInput = (value: string) => {
emit('update:modelValue', value);
};
// 同步滚动位置
const syncScroll = (e: Event) => {
console.log('syncScroll');
scrollTop.value = (e.target as HTMLTextAreaElement).scrollTop;
};
const escapeHtml = (str: string): string => {
if (!isString(str)) return '';
return str
@ -115,11 +98,24 @@ const generateHighlightedHtml = (): string => {
};
onMounted(() => {
document.querySelector('.textarea-input .arco-textarea')?.addEventListener('scroll', handleTextareaScroll);
nativeTextarea = (textareaWrapRef.value?.$el || textareaWrapRef.value)?.querySelector?.('textarea.arco-textarea') ||
document.querySelector('.textarea-input .arco-textarea');
if (nativeTextarea) {
nativeTextarea.addEventListener('scroll', handleTextareaScroll);
nativeTextarea.addEventListener('compositionstart', handleCompositionUpdate);
nativeTextarea.addEventListener('compositionupdate', handleCompositionUpdate);
nativeTextarea.addEventListener('compositionend', handleCompositionUpdate);
}
});
onUnmounted(() => {
document.querySelector('.textarea-input .arco-textarea')?.removeEventListener('scroll', handleTextareaScroll);
if (nativeTextarea) {
nativeTextarea.removeEventListener('scroll', handleTextareaScroll);
nativeTextarea.removeEventListener('compositionstart', handleCompositionUpdate);
nativeTextarea.removeEventListener('compositionupdate', handleCompositionUpdate);
nativeTextarea.removeEventListener('compositionend', handleCompositionUpdate);
}
});
const handleTextareaScroll = (e: Event) => {
@ -130,6 +126,18 @@ const handleTextareaScroll = (e: Event) => {
highlightElement.scrollTop = _scrollTop;
}
};
const handleCompositionUpdate = () => {
if (!nativeTextarea) return;
// 使用 rAF 等待浏览器把最新字符写入 textarea.value 再读取
requestAnimationFrame(() => {
if (!nativeTextarea) return;
const latest = nativeTextarea.value.slice(0, 1000);
if (latest !== inputValue.value) {
inputValue.value = latest;
}
});
};
</script>
<style scoped lang="scss">
@ -137,9 +145,11 @@ const handleTextareaScroll = (e: Event) => {
position: relative;
width: 100%;
height: 100%;
@mixin textarea-padding {
padding: 8px 12px;
}
@mixin textarea-style {
box-sizing: border-box;
width: 100%;
@ -154,6 +164,7 @@ const handleTextareaScroll = (e: Event) => {
word-wrap: break-word;
z-index: inherit;
}
.textarea-highlight {
@include textarea-style;
position: absolute;
@ -164,13 +175,16 @@ const handleTextareaScroll = (e: Event) => {
background: #fff;
overflow: hidden;
@include textarea-padding;
&.focus {
border-color: rgb(var(--primary-6)) !important;
box-shadow: 0 0 0 0 var(--color-primary-light-2) !important;
}
}
:deep(.arco-textarea-wrapper) {
@include textarea-style;
.arco-textarea {
padding: 0;
overflow: hidden;
@ -185,14 +199,22 @@ const handleTextareaScroll = (e: Event) => {
// -webkit-text-fill-color: transparent !important;
overflow-y: auto;
}
.arco-textarea-word-limit {
z-index: 2;
}
&.arco-textarea-disabled {
.arco-textarea {
background: #f2f3f5;
}
}
}
// 处于中文输入法合成态时,显示真实文本并隐藏高亮层
:deep(.textarea-input.composing .arco-textarea) {
color: #211f24 !important;
-webkit-text-fill-color: #211f24 !important;
}
}
</style>