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

View File

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