Browse Source

feat: textArea support row number

林艳 2 months ago
parent
commit
f32e233614

+ 31 - 0
packages/semi-foundation/input/textarea.scss

@@ -193,6 +193,8 @@ $module: #{$prefix}-input;
             color: $color-input_counter_danger-text-default;
         }
     }
+
+    // &-withLineNumbers 由组件运行时注入左侧内边距,无需静态样式
 }
 
 .#{$module}-textarea-borderless{
@@ -229,4 +231,33 @@ $module: #{$prefix}-input;
 
 }
 
+/* 行号容器与样式 */
+.#{$module}-textarea-linenumbers {
+    position: absolute;
+    top: 0;
+    left: 0;
+    bottom: 0;
+    width: var(--semi-input-textarea-linenumber-width, 40px);
+    box-sizing: border-box;
+    padding: $spacing-textarea-paddingY 8px; // 左右各 8px,与 paddingLeft 16px 配合
+    background-color: transparent;
+    color: $color-input_placeholder-text-default;
+    text-align: right;
+    overflow: hidden; /* 自身不出现横向滚动 */
+    pointer-events: none; /* 避免遮挡交互 */
+    font-variant-numeric: tabular-nums; // 等宽数字,避免数字宽度不一致
+    z-index: 1; // 置于 textarea 之上,避免被内容覆盖
+}
+
+.#{$module}-textarea-linenumbers-content {
+    /* 由父容器负责裁剪,这里需要让内容自然撑高以容纳所有行号 */
+    position: relative;
+    will-change: transform;
+}
+
+.#{$module}-textarea-linenumbers-item {
+    line-height: var(--semi-input-textarea-lineheight, $height-input_default); /* 与文本行高一致(随变量) */
+    white-space: nowrap;
+}
+
 @import "./rtl.scss";

+ 31 - 0
packages/semi-ui/input/_story/input.stories.jsx

@@ -915,6 +915,37 @@ export const TextAreaAutosize = () => {
 };
 TextAreaAutosize.storyName = "textarea autosize";
 
+export const TextareaLineNumbers = () => {
+  const [value, setValue] = React.useState(`import React from 'react';
+function Example() {
+  return <div>Hello Semi</div>;
+}
+export default Example;`);
+  return (
+    <div className="input" style={{ width: 520 }}>
+      <TextArea
+        showLineNumbers
+        rows={8}
+        value={value}
+        onChange={(v) => setValue(v)}
+        placeholder="Type multi-line code here"
+        lineNumberMaxDigits={3}
+      />
+      <br />
+      <TextArea
+        showLineNumbers
+        autosize
+        lineNumberStart={100}
+        lineNumberWidth={56}
+        renderLineNumber={(n) => String(n).padStart(3, '0')}
+        defaultValue={`A\nB\nC\nD\nE\nF\nG\nH\nI\nJ`}
+        lineNumberMaxDigits={3}
+      />
+    </div>
+  );
+};
+TextareaLineNumbers.storyName = 'textarea with line numbers';
+
 export const InputA11y = () => {
   return (
     <div style={{ width: 300 }}>

+ 105 - 0
packages/semi-ui/input/textarea.tsx

@@ -44,6 +44,16 @@ export interface TextAreaProps extends Omit<React.TextareaHTMLAttributes<HTMLTex
     autoFocus?: boolean;
     showCounter?: boolean;
     showClear?: boolean;
+    /** 是否显示行号 */
+    showLineNumbers?: boolean;
+    /** 行号起始值,默认 1 */
+    lineNumberStart?: number;
+    /** 行号列宽度(px),默认 40 */
+    lineNumberWidth?: number;
+    /** 预留的行号最大位数,设置后行号栏宽度将固定,不会随位数变化抖动 */
+    lineNumberMaxDigits?: number;
+    /** 自定义渲染行号 */
+    renderLineNumber?: (lineNumber: number) => React.ReactNode;
     onClear?: (e: React.MouseEvent<HTMLTextAreaElement>) => void;
     onChange?: (value: string, e: React.MouseEvent<HTMLTextAreaElement>) => void;
     onBlur?: (e: React.FocusEvent<HTMLTextAreaElement>) => void;
@@ -70,6 +80,8 @@ export interface TextAreaState {
     isHover: boolean;
     height: number;
     minLength: number;
+    /** 视口可见行数(用于保证行号填满可视区) */
+    displayLineCount?: number;
     cachedValue?: string
 }
 
@@ -87,6 +99,10 @@ class TextArea extends BaseComponent<TextAreaProps, TextAreaState> {
         className: PropTypes.string,
         style: PropTypes.object,
         showClear: PropTypes.bool,
+        showLineNumbers: PropTypes.bool,
+        lineNumberStart: PropTypes.number,
+        lineNumberWidth: PropTypes.number,
+        renderLineNumber: PropTypes.func,
         onClear: PropTypes.func,
         onResize: PropTypes.func,
         getValueLength: PropTypes.func,
@@ -102,6 +118,10 @@ class TextArea extends BaseComponent<TextAreaProps, TextAreaState> {
         cols: 20,
         showCounter: false,
         showClear: false,
+        showLineNumbers: false,
+        lineNumberStart: 1,
+        lineNumberWidth: 40,
+        lineNumberMaxDigits: undefined,
         onEnterPress: noop,
         onChange: noop,
         onBlur: noop,
@@ -116,6 +136,8 @@ class TextArea extends BaseComponent<TextAreaProps, TextAreaState> {
     libRef: React.RefObject<HTMLInputElement>;
     foundation: TextAreaFoundation;
     throttledResizeTextarea: DebouncedFunc<typeof this.foundation.resizeTextarea>;
+    lineNumberGutterRef: React.RefObject<HTMLDivElement>;
+    lineNumberContentRef: React.RefObject<HTMLDivElement>;
 
     constructor(props: TextAreaProps) {
         super(props);
@@ -126,6 +148,7 @@ class TextArea extends BaseComponent<TextAreaProps, TextAreaState> {
             isHover: false,
             height: 0,
             minLength: props.minLength,
+            displayLineCount: props?.rows || 1,
             cachedValue: props.value,
         };
         this.focusing = false;
@@ -133,6 +156,8 @@ class TextArea extends BaseComponent<TextAreaProps, TextAreaState> {
 
         this.libRef = React.createRef<HTMLInputElement>();
         this.throttledResizeTextarea = throttle(this.foundation.resizeTextarea, 10);
+        this.lineNumberGutterRef = React.createRef<HTMLDivElement>();
+        this.lineNumberContentRef = React.createRef<HTMLDivElement>();
     }
 
     get adapter() {
@@ -185,6 +210,11 @@ class TextArea extends BaseComponent<TextAreaProps, TextAreaState> {
         }
     }
 
+    componentDidMount(): void {
+        this.syncLineNumberLineHeight();
+        this.updateDisplayLineCount();
+    }
+
     componentDidUpdate(prevProps: TextAreaProps, prevState: TextAreaState) {
         if (
             (this.props.value !== prevProps.value || this.props.placeholder !== prevProps.placeholder) &&
@@ -192,6 +222,44 @@ class TextArea extends BaseComponent<TextAreaProps, TextAreaState> {
         ) {
             this.foundation.resizeTextarea();
         }
+        // 同步滚动位置(通过内容容器 translateY,实现与 textarea 垂直滚动同步)
+        const textEl = this.libRef?.current as unknown as HTMLTextAreaElement;
+        const contentEl = this.lineNumberContentRef?.current as HTMLDivElement;
+        if (textEl && contentEl) {
+            contentEl.style.transform = `translateY(${-textEl.scrollTop}px)`;
+        }
+        this.syncLineNumberLineHeight();
+        this.updateDisplayLineCount();
+    }
+
+    private syncLineNumberLineHeight() {
+        const textEl = this.libRef?.current as unknown as HTMLTextAreaElement;
+        const gutter = this.lineNumberGutterRef?.current as HTMLDivElement;
+        if (!textEl || !gutter) return;
+        if (typeof window === 'undefined' || typeof window.getComputedStyle !== 'function') return;
+        const computed = window.getComputedStyle(textEl);
+        const lh = computed.lineHeight;
+        if (lh && gutter.style.getPropertyValue('--semi-input-textarea-lineheight') !== lh) {
+            gutter.style.setProperty('--semi-input-textarea-lineheight', lh);
+        }
+        const fs = computed.fontSize;
+        if (fs) {
+            gutter.style.setProperty('font-size', fs);
+        }
+    }
+
+    private updateDisplayLineCount() {
+        const textEl = this.libRef?.current as unknown as HTMLTextAreaElement;
+        if (!textEl) return;
+        if (typeof window === 'undefined' || typeof window.getComputedStyle !== 'function') return;
+        const computed = window.getComputedStyle(textEl);
+        const lhStr = computed.lineHeight;
+        const lineHeight = lhStr.endsWith('px') ? parseFloat(lhStr) : parseFloat(lhStr) || 16;
+        const clientH = textEl.clientHeight || 0;
+        const visible = lineHeight > 0 ? Math.ceil(clientH / lineHeight) : (this.props.rows || 1);
+        if (visible && visible !== this.state.displayLineCount) {
+            this.setState({ displayLineCount: visible });
+        }
     }
 
     handleClear = (e: React.MouseEvent<HTMLDivElement>) => {
@@ -271,6 +339,11 @@ class TextArea extends BaseComponent<TextAreaProps, TextAreaState> {
             showClear,
             borderless,
             autoFocus,
+            showLineNumbers,
+            lineNumberStart,
+            lineNumberWidth,
+            lineNumberMaxDigits,
+            renderLineNumber,
             ...rest
         } = this.props;
         const { isFocus, value, minLength: stateMinLength } = this.state;
@@ -288,7 +361,19 @@ class TextArea extends BaseComponent<TextAreaProps, TextAreaState> {
             [`${prefixCls}-textarea-readonly`]: readonly,
             [`${prefixCls}-textarea-autosize`]: isObject(autosize) ? isUndefined(autosize?.maxRows) : autosize,
             [`${prefixCls}-textarea-showClear`]: showClear,
+            [`${prefixCls}-textarea-withLineNumbers`]: showLineNumbers,
         });
+        const logicalLines = typeof value === 'string' && value.length > 0 ? value.split(/\r\n|\r|\n/).length : 0;
+        const minRows = this.props.rows || 1;
+        const lineCount = Math.max(logicalLines || minRows, this.state.displayLineCount || minRows);
+        const currentMaxDigits = String((lineNumberStart as number) + Math.max(lineCount - 1, 0)).length;
+        const reservedDigits = Math.max(lineNumberMaxDigits || 0, currentMaxDigits);
+        const gutterWidthVar = lineNumberMaxDigits
+            ? `calc(${reservedDigits}ch + 16px)`
+            : `${lineNumberWidth}px`;
+        const gutterStyle: React.CSSProperties = {
+            ['--semi-input-textarea-linenumber-width' as any]: gutterWidthVar,
+        };
         const itemProps = {
             ...omit(rest, 'insetLabel', 'insetLabelId', 'getValueLength', 'onClear', 'showClear', 'disabledEnterStartNewLine'),
             autoFocus: autoFocus || this.props['autofocus'],
@@ -300,9 +385,15 @@ class TextArea extends BaseComponent<TextAreaProps, TextAreaState> {
             onFocus: (e: React.FocusEvent<HTMLTextAreaElement>) => this.foundation.handleFocus(e),
             onBlur: (e: React.FocusEvent<HTMLTextAreaElement>) => this.foundation.handleBlur(e.nativeEvent),
             onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => this.foundation.handleKeyDown(e),
+            onScroll: (e: React.UIEvent<HTMLTextAreaElement>) => {
+                const target = e.currentTarget;
+                const contentEl = this.lineNumberContentRef?.current as HTMLDivElement;
+                if (contentEl) contentEl.style.transform = `translateY(${-target.scrollTop}px)`;
+            },
             value: value === null || value === undefined ? '' : value,
             onCompositionStart: this.foundation.handleCompositionStart,
             onCompositionEnd: this.foundation.handleCompositionEnd,
+            style: showLineNumbers ? { paddingLeft: 'calc(40px + 16px)' } : undefined,
         };
         if (!isFunction(getValueLength)) {
             (itemProps as any).maxLength = maxLength;
@@ -318,6 +409,20 @@ class TextArea extends BaseComponent<TextAreaProps, TextAreaState> {
                 onMouseEnter={e => this.foundation.handleMouseEnter(e)}
                 onMouseLeave={e => this.foundation.handleMouseLeave(e)}
             >
+                {showLineNumbers ? (
+                    <div className={`${prefixCls}-textarea-linenumbers`} style={gutterStyle} ref={this.lineNumberGutterRef} aria-hidden={true}>
+                        <div className={`${prefixCls}-textarea-linenumbers-content`} ref={this.lineNumberContentRef}>
+                            {Array.from({ length: lineCount }, (_, idx) => {
+                                const num = (lineNumberStart as number) + idx;
+                                return (
+                                    <div key={idx} className={`${prefixCls}-textarea-linenumbers-item`}>
+                                        {typeof renderLineNumber === 'function' ? renderLineNumber(num) : num}
+                                    </div>
+                                );
+                            })}
+                        </div>
+                    </div>
+                ) : null}
                 {autosize ? (
                     <ResizeObserver onResize={this.throttledResizeTextarea}>
                         <textarea {...itemProps} ref={this.setRef} />