refactor(web): extract LineNumberTextarea and refactor Environments.vue

- Extracted reusable LineNumberTextarea component

- Refactored Environments.vue by extracting EditEnvDialog and DeleteEnvDialog

- Fixed Sonner toast font inheritance

- Removed fixed placeholder font in LineNumberTextarea
This commit is contained in:
duorameng
2026-06-08 09:03:55 +08:00
parent 39c714c050
commit f034c3d882
5 changed files with 366 additions and 254 deletions
+121
View File
@@ -0,0 +1,121 @@
<script setup lang="ts">
import { ref, watch, nextTick, onBeforeUnmount, onMounted } from 'vue'
const props = withDefaults(defineProps<{
modelValue?: string
placeholder?: string
rows?: number
minHeightClass?: string
maxHeightClass?: string
}>(), {
modelValue: '',
placeholder: '',
rows: 5,
minHeightClass: 'min-h-16',
maxHeightClass: 'max-h-40'
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
const valueTextareaRef = ref<HTMLTextAreaElement | null>(null)
const lineNumbersRef = ref<HTMLDivElement | null>(null)
const lineMeasureRef = ref<HTMLDivElement | null>(null)
const visualLineNumbers = ref<string[]>(['1'])
let textareaResizeObserver: ResizeObserver | null = null
function syncValueLineNumbers() {
if (!valueTextareaRef.value || !lineNumbersRef.value) return
lineNumbersRef.value.scrollTop = valueTextareaRef.value.scrollTop
}
async function updateVisualLineNumbers() {
await nextTick()
const textarea = valueTextareaRef.value
const measure = lineMeasureRef.value
if (!textarea || !measure) return
const style = window.getComputedStyle(textarea)
const lineHeight = Number.parseFloat(style.lineHeight) || Number.parseFloat(style.fontSize) * 1.5 || 24
const lines = String(props.modelValue ?? '').split('\n')
measure.style.width = `${textarea.clientWidth}px`
measure.innerHTML = ''
const nextLineNumbers: string[] = []
lines.forEach((line, index) => {
const lineEl = document.createElement('div')
lineEl.className = 'break-all whitespace-pre-wrap'
lineEl.textContent = line || ' '
measure.appendChild(lineEl)
const visualRows = Math.max(1, Math.round(lineEl.getBoundingClientRect().height / lineHeight))
nextLineNumbers.push(String(index + 1))
for (let i = 1; i < visualRows; i += 1) {
nextLineNumbers.push('\u00A0')
}
})
visualLineNumbers.value = nextLineNumbers.length > 0 ? nextLineNumbers : ['1']
syncValueLineNumbers()
}
watch(() => props.modelValue, () => {
void updateVisualLineNumbers()
})
onMounted(() => {
updateVisualLineNumbers()
if (valueTextareaRef.value) {
textareaResizeObserver = new ResizeObserver(() => {
void updateVisualLineNumbers()
})
textareaResizeObserver.observe(valueTextareaRef.value)
}
})
onBeforeUnmount(() => {
textareaResizeObserver?.disconnect()
})
function onInput(e: Event) {
const target = e.target as HTMLTextAreaElement
emit('update:modelValue', target.value)
}
// 暴露更新方法,供父组件弹窗打开等时机手动触发重绘
defineExpose({
updateVisualLineNumbers
})
</script>
<template>
<div>
<div class="relative flex min-w-0 overflow-hidden rounded-md border border-input bg-transparent shadow-xs focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]">
<div ref="lineNumbersRef" :class="['flex w-6 shrink-0 flex-col overflow-hidden border-r border-border bg-muted/30 py-2 text-right font-mono text-[10px] leading-6 text-muted-foreground', maxHeightClass]">
<span v-for="(line, index) in visualLineNumbers" :key="`${index}-${line}`" class="block h-6 px-1">{{ line }}</span>
</div>
<textarea
ref="valueTextareaRef"
:value="modelValue"
@input="onInput"
:rows="rows"
:placeholder="placeholder"
:class="['w-full min-w-0 resize-none overflow-x-hidden bg-transparent pl-2 pr-3 py-2 text-sm leading-6 break-all whitespace-pre-wrap outline-none', minHeightClass, maxHeightClass]"
@scroll="syncValueLineNumbers"
/>
<div aria-hidden="true" class="pointer-events-none absolute bottom-1.5 right-1.5 h-3.5 w-3.5 opacity-45">
<span class="absolute bottom-0 right-0 h-px w-3 rotate-[-45deg] bg-border" />
<span class="absolute bottom-1 right-0.5 h-px w-2 rotate-[-45deg] bg-border/80" />
<span class="absolute bottom-2 right-1 h-px w-1 rotate-[-45deg] bg-border/60" />
</div>
</div>
<div
ref="lineMeasureRef"
aria-hidden="true"
:class="['pointer-events-none invisible fixed left-0 top-0 -z-10 break-all whitespace-pre-wrap px-2 py-2 text-sm leading-6', minHeightClass]"
/>
</div>
</template>