fix(app): fix perf issues and bugs with textarea autosizing (#20)
This commit is contained in:
parent
692a231d14
commit
3a72b8d3c4
@ -106,6 +106,13 @@ import * as PlaceholderUtils from '~/utils/PlaceholderUtils';
|
|||||||
import wrapperStyles from './textarea/InputWrapper.module.css';
|
import wrapperStyles from './textarea/InputWrapper.module.css';
|
||||||
import styles from './textarea/TextareaInput.module.css';
|
import styles from './textarea/TextareaInput.module.css';
|
||||||
|
|
||||||
|
function readBorderBoxBlockSize(entry: ResizeObserverEntry): number {
|
||||||
|
const bbs: any = (entry as any).borderBoxSize;
|
||||||
|
if (Array.isArray(bbs) && bbs[0] && typeof bbs[0].blockSize === 'number') return bbs[0].blockSize;
|
||||||
|
if (bbs && typeof bbs.blockSize === 'number') return bbs.blockSize;
|
||||||
|
return (entry.target as HTMLElement).getBoundingClientRect().height;
|
||||||
|
}
|
||||||
|
|
||||||
const ChannelTextareaContent = observer(
|
const ChannelTextareaContent = observer(
|
||||||
({
|
({
|
||||||
channel,
|
channel,
|
||||||
@ -123,7 +130,6 @@ const ChannelTextareaContent = observer(
|
|||||||
const [isInputAreaFocused, setIsInputAreaFocused] = React.useState(false);
|
const [isInputAreaFocused, setIsInputAreaFocused] = React.useState(false);
|
||||||
const [value, setValue] = React.useState('');
|
const [value, setValue] = React.useState('');
|
||||||
const [showAllButtons, setShowAllButtons] = React.useState(true);
|
const [showAllButtons, setShowAllButtons] = React.useState(true);
|
||||||
const [_textareaHeight, setTextareaHeight] = React.useState(0);
|
|
||||||
const [containerWidth, setContainerWidth] = React.useState(0);
|
const [containerWidth, setContainerWidth] = React.useState(0);
|
||||||
const [pendingMentionConfirmation, setPendingMentionConfirmation] = React.useState<MentionConfirmationInfo | null>(
|
const [pendingMentionConfirmation, setPendingMentionConfirmation] = React.useState<MentionConfirmationInfo | null>(
|
||||||
null,
|
null,
|
||||||
@ -139,6 +145,64 @@ const ChannelTextareaContent = observer(
|
|||||||
const scrollerRef = React.useRef<ScrollerHandle>(null);
|
const scrollerRef = React.useRef<ScrollerHandle>(null);
|
||||||
useMarkdownKeybinds(isFocused);
|
useMarkdownKeybinds(isFocused);
|
||||||
|
|
||||||
|
const textareaHeightRef = React.useRef<number>(0);
|
||||||
|
const handleTextareaHeightChange = React.useCallback((height: number) => {
|
||||||
|
textareaHeightRef.current = height;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const inputBoxHeightRef = React.useRef<number | null>(null);
|
||||||
|
const pendingLayoutDeltaRef = React.useRef(0);
|
||||||
|
const flushScheduledRef = React.useRef(false);
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el || typeof ResizeObserver === 'undefined') return;
|
||||||
|
|
||||||
|
inputBoxHeightRef.current = null;
|
||||||
|
pendingLayoutDeltaRef.current = 0;
|
||||||
|
flushScheduledRef.current = false;
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
flushScheduledRef.current = false;
|
||||||
|
const delta = pendingLayoutDeltaRef.current;
|
||||||
|
pendingLayoutDeltaRef.current = 0;
|
||||||
|
if (!delta) return;
|
||||||
|
if (delta <= 0) return;
|
||||||
|
|
||||||
|
ComponentDispatch.dispatch('LAYOUT_RESIZED', {
|
||||||
|
channelId: channel.id,
|
||||||
|
heightDelta: delta,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const ro = new ResizeObserver((entries) => {
|
||||||
|
const entry = entries[0];
|
||||||
|
if (!entry) return;
|
||||||
|
|
||||||
|
const nextHeight = Math.round(readBorderBoxBlockSize(entry));
|
||||||
|
const prevHeight = inputBoxHeightRef.current;
|
||||||
|
|
||||||
|
if (prevHeight == null) {
|
||||||
|
inputBoxHeightRef.current = nextHeight;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delta = nextHeight - prevHeight;
|
||||||
|
if (!delta) return;
|
||||||
|
|
||||||
|
inputBoxHeightRef.current = nextHeight;
|
||||||
|
pendingLayoutDeltaRef.current += delta;
|
||||||
|
|
||||||
|
if (!flushScheduledRef.current) {
|
||||||
|
flushScheduledRef.current = true;
|
||||||
|
queueMicrotask(flush);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [channel.id]);
|
||||||
|
|
||||||
const showGiftButton = AccessibilityStore.showGiftButton;
|
const showGiftButton = AccessibilityStore.showGiftButton;
|
||||||
const showGifButton = AccessibilityStore.showGifButton;
|
const showGifButton = AccessibilityStore.showGifButton;
|
||||||
const showMemesButton = AccessibilityStore.showMemesButton;
|
const showMemesButton = AccessibilityStore.showMemesButton;
|
||||||
@ -653,9 +717,15 @@ const ChannelTextareaContent = observer(
|
|||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
let lastWidth = -1;
|
||||||
|
|
||||||
const checkButtonVisibility = () => {
|
const checkButtonVisibility = () => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
const containerWidthLocal = containerRef.current.offsetWidth;
|
const containerWidthLocal = containerRef.current.offsetWidth;
|
||||||
|
|
||||||
|
if (containerWidthLocal === lastWidth) return;
|
||||||
|
lastWidth = containerWidthLocal;
|
||||||
|
|
||||||
const shouldShowAll = containerWidthLocal > 500;
|
const shouldShowAll = containerWidthLocal > 500;
|
||||||
setShowAllButtons(shouldShowAll);
|
setShowAllButtons(shouldShowAll);
|
||||||
setContainerWidth(containerWidthLocal);
|
setContainerWidth(containerWidthLocal);
|
||||||
@ -765,12 +835,12 @@ const ChannelTextareaContent = observer(
|
|||||||
<Scroller ref={scrollerRef} fade={true} className={styles.scroller} key="channel-textarea-scroller">
|
<Scroller ref={scrollerRef} fade={true} className={styles.scroller} key="channel-textarea-scroller">
|
||||||
<div style={{display: 'flex', flexDirection: 'column'}}>
|
<div style={{display: 'flex', flexDirection: 'column'}}>
|
||||||
<TextareaInputField
|
<TextareaInputField
|
||||||
|
channelId={channel.id}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
isMobile={mobileLayout.enabled}
|
isMobile={mobileLayout.enabled}
|
||||||
value={value}
|
value={value}
|
||||||
placeholder={placeholderText}
|
placeholder={placeholderText}
|
||||||
textareaRef={textareaRef}
|
textareaRef={textareaRef}
|
||||||
scrollerRef={scrollerRef}
|
|
||||||
isFocused={isFocused}
|
isFocused={isFocused}
|
||||||
isAutocompleteAttached={isAutocompleteAttached}
|
isAutocompleteAttached={isAutocompleteAttached}
|
||||||
autocompleteOptions={autocompleteOptions}
|
autocompleteOptions={autocompleteOptions}
|
||||||
@ -787,7 +857,7 @@ const ChannelTextareaContent = observer(
|
|||||||
handleTextChange(newValue, previousValueRef.current);
|
handleTextChange(newValue, previousValueRef.current);
|
||||||
setValue(newValue);
|
setValue(newValue);
|
||||||
}}
|
}}
|
||||||
onHeightChange={setTextareaHeight}
|
onHeightChange={handleTextareaHeightChange}
|
||||||
onCursorMove={onCursorMove}
|
onCursorMove={onCursorMove}
|
||||||
onArrowUp={handleArrowUp}
|
onArrowUp={handleArrowUp}
|
||||||
onEnter={handleSubmit}
|
onEnter={handleSubmit}
|
||||||
|
|||||||
@ -221,6 +221,7 @@ export const EditingMessageInput = observer(
|
|||||||
style={{position: 'absolute', visibility: 'hidden', pointerEvents: 'none'}}
|
style={{position: 'absolute', visibility: 'hidden', pointerEvents: 'none'}}
|
||||||
/>
|
/>
|
||||||
<TextareaInputField
|
<TextareaInputField
|
||||||
|
channelId={channel.id}
|
||||||
disabled={false}
|
disabled={false}
|
||||||
isMobile={mobileLayout.enabled}
|
isMobile={mobileLayout.enabled}
|
||||||
value={value}
|
value={value}
|
||||||
|
|||||||
@ -30,6 +30,10 @@
|
|||||||
--message-group-spacing: 16px;
|
--message-group-spacing: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nativeAnchor {
|
||||||
|
overflow-anchor: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
.scrollerContainer {
|
.scrollerContainer {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
@ -718,6 +718,7 @@ export const Messages = observer(function Messages({channel}: {channel: ChannelR
|
|||||||
{topBar}
|
{topBar}
|
||||||
<div className={styles.scrollerContainer}>
|
<div className={styles.scrollerContainer}>
|
||||||
<Scroller
|
<Scroller
|
||||||
|
className={state.isAtBottom ? styles.nativeAnchor : undefined}
|
||||||
fade={false}
|
fade={false}
|
||||||
scrollbar="regular"
|
scrollbar="regular"
|
||||||
hideThumbWhenWindowBlurred
|
hideThumbWhenWindowBlurred
|
||||||
|
|||||||
@ -72,7 +72,7 @@
|
|||||||
.textarea {
|
.textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
resize: none;
|
resize: none;
|
||||||
overflow: visible;
|
overflow: hidden;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
@ -88,6 +88,12 @@
|
|||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@supports (field-sizing: content) {
|
||||||
|
.textarea {
|
||||||
|
field-sizing: content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.textarea:disabled {
|
.textarea:disabled {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,19 +21,18 @@ import {clsx} from 'clsx';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import * as HighlightActionCreators from '~/actions/HighlightActionCreators';
|
import * as HighlightActionCreators from '~/actions/HighlightActionCreators';
|
||||||
import {type AutocompleteOption, isChannel} from '~/components/channel/Autocomplete';
|
import {type AutocompleteOption, isChannel} from '~/components/channel/Autocomplete';
|
||||||
import type {ScrollerHandle} from '~/components/uikit/Scroller';
|
|
||||||
import {useTextareaAutofocus} from '~/hooks/useTextareaAutofocus';
|
import {useTextareaAutofocus} from '~/hooks/useTextareaAutofocus';
|
||||||
import {ComponentDispatch} from '~/lib/ComponentDispatch';
|
|
||||||
import {TextareaAutosize} from '~/lib/TextareaAutosize';
|
import {TextareaAutosize} from '~/lib/TextareaAutosize';
|
||||||
import styles from './TextareaInput.module.css';
|
import styles from './TextareaInput.module.css';
|
||||||
|
|
||||||
interface TextareaInputFieldProps {
|
interface TextareaInputFieldProps {
|
||||||
|
channelId: string;
|
||||||
|
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
value: string;
|
value: string;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
|
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
|
||||||
scrollerRef?: React.RefObject<ScrollerHandle | null> | null;
|
|
||||||
shouldStickToBottomRef?: React.MutableRefObject<boolean>;
|
shouldStickToBottomRef?: React.MutableRefObject<boolean>;
|
||||||
isFocused?: boolean;
|
isFocused?: boolean;
|
||||||
isAutocompleteAttached: boolean;
|
isAutocompleteAttached: boolean;
|
||||||
@ -60,7 +59,6 @@ export const TextareaInputField = React.forwardRef<HTMLTextAreaElement, Textarea
|
|||||||
value,
|
value,
|
||||||
placeholder,
|
placeholder,
|
||||||
textareaRef,
|
textareaRef,
|
||||||
scrollerRef,
|
|
||||||
isAutocompleteAttached,
|
isAutocompleteAttached,
|
||||||
autocompleteOptions,
|
autocompleteOptions,
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
@ -75,12 +73,9 @@ export const TextareaInputField = React.forwardRef<HTMLTextAreaElement, Textarea
|
|||||||
setSelectedIndex,
|
setSelectedIndex,
|
||||||
className,
|
className,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
shouldStickToBottomRef,
|
|
||||||
},
|
},
|
||||||
_ref,
|
_ref,
|
||||||
) => {
|
) => {
|
||||||
const lastHeightRef = React.useRef<number>(0);
|
|
||||||
|
|
||||||
useTextareaAutofocus(textareaRef, isMobile, !disabled);
|
useTextareaAutofocus(textareaRef, isMobile, !disabled);
|
||||||
|
|
||||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
@ -124,49 +119,6 @@ export const TextareaInputField = React.forwardRef<HTMLTextAreaElement, Textarea
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleHeightChange = (height: number, meta?: {rowHeight: number}) => {
|
|
||||||
const clampToSingleRow = () => {
|
|
||||||
if (value.length > 0 || !textareaRef.current || !meta?.rowHeight) {
|
|
||||||
return height;
|
|
||||||
}
|
|
||||||
|
|
||||||
const style = window.getComputedStyle(textareaRef.current);
|
|
||||||
const padding = (parseFloat(style.paddingTop || '0') || 0) + (parseFloat(style.paddingBottom || '0') || 0);
|
|
||||||
const border =
|
|
||||||
(parseFloat(style.borderTopWidth || '0') || 0) + (parseFloat(style.borderBottomWidth || '0') || 0);
|
|
||||||
const singleRowHeight = meta.rowHeight + padding + border;
|
|
||||||
|
|
||||||
if (!Number.isFinite(singleRowHeight) || singleRowHeight <= 0 || singleRowHeight >= height) {
|
|
||||||
return height;
|
|
||||||
}
|
|
||||||
|
|
||||||
textareaRef.current.style.setProperty('height', `${singleRowHeight}px`, 'important');
|
|
||||||
return singleRowHeight;
|
|
||||||
};
|
|
||||||
|
|
||||||
const adjustedHeight = clampToSingleRow();
|
|
||||||
|
|
||||||
if (adjustedHeight === lastHeightRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const heightDelta = adjustedHeight - lastHeightRef.current;
|
|
||||||
lastHeightRef.current = adjustedHeight;
|
|
||||||
|
|
||||||
const distanceFromBottom = scrollerRef?.current?.getDistanceFromBottom?.() ?? 0;
|
|
||||||
const shouldStickToBottom = shouldStickToBottomRef?.current ?? distanceFromBottom <= 8;
|
|
||||||
|
|
||||||
onHeightChange(adjustedHeight);
|
|
||||||
|
|
||||||
if (shouldStickToBottom) {
|
|
||||||
scrollerRef?.current?.scrollToBottom({animate: false});
|
|
||||||
}
|
|
||||||
|
|
||||||
queueMicrotask(() => {
|
|
||||||
ComponentDispatch.dispatch('LAYOUT_RESIZED', {heightDelta});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TextareaAutosize
|
<TextareaAutosize
|
||||||
data-channel-textarea
|
data-channel-textarea
|
||||||
@ -176,7 +128,7 @@ export const TextareaInputField = React.forwardRef<HTMLTextAreaElement, Textarea
|
|||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
onChange={(event) => onChange(event.target.value)}
|
onChange={(event) => onChange(event.target.value)}
|
||||||
onFocus={onFocus}
|
onFocus={onFocus}
|
||||||
onHeightChange={handleHeightChange}
|
onHeightChange={(h) => onHeightChange(h)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
|
|||||||
@ -51,16 +51,96 @@ function computeRowConstraints(el: HTMLTextAreaElement, minRows?: number, maxRow
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function supportsFieldSizingContent(): boolean {
|
||||||
|
try {
|
||||||
|
return typeof CSS !== 'undefined' && typeof CSS.supports === 'function' && CSS.supports('field-sizing: content');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeValueForMeasurement(value: string): string {
|
||||||
|
if (!value.includes('\n')) return value;
|
||||||
|
const parts = value.split('\n');
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
if (parts[i] === '') parts[i] = '\u200b';
|
||||||
|
}
|
||||||
|
return parts.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureMeasureEl(): HTMLTextAreaElement | null {
|
||||||
|
if (typeof document === 'undefined') return null;
|
||||||
|
const el = document.createElement('textarea');
|
||||||
|
el.setAttribute('aria-hidden', 'true');
|
||||||
|
el.tabIndex = -1;
|
||||||
|
el.style.position = 'absolute';
|
||||||
|
el.style.top = '0';
|
||||||
|
el.style.left = '-9999px';
|
||||||
|
el.style.height = '0px';
|
||||||
|
el.style.overflow = 'hidden';
|
||||||
|
el.style.visibility = 'hidden';
|
||||||
|
el.style.pointerEvents = 'none';
|
||||||
|
el.style.zIndex = '-1';
|
||||||
|
document.body.appendChild(el);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncMeasureStyles(target: HTMLTextAreaElement, measure: HTMLTextAreaElement) {
|
||||||
|
const cs = window.getComputedStyle(target);
|
||||||
|
|
||||||
|
measure.style.boxSizing = cs.boxSizing;
|
||||||
|
measure.style.width = `${target.getBoundingClientRect().width}px`;
|
||||||
|
|
||||||
|
measure.style.font = cs.font;
|
||||||
|
measure.style.fontFamily = cs.fontFamily;
|
||||||
|
measure.style.fontSize = cs.fontSize;
|
||||||
|
measure.style.fontWeight = cs.fontWeight;
|
||||||
|
measure.style.fontStyle = cs.fontStyle;
|
||||||
|
measure.style.letterSpacing = cs.letterSpacing;
|
||||||
|
measure.style.textTransform = cs.textTransform;
|
||||||
|
measure.style.textRendering = cs.textRendering as any;
|
||||||
|
|
||||||
|
measure.style.lineHeight = cs.lineHeight;
|
||||||
|
measure.style.whiteSpace = cs.whiteSpace;
|
||||||
|
measure.style.wordBreak = cs.wordBreak;
|
||||||
|
measure.style.overflowWrap = (cs as any).overflowWrap ?? 'normal';
|
||||||
|
measure.style.tabSize = (cs as any).tabSize ?? '8';
|
||||||
|
|
||||||
|
measure.style.paddingTop = cs.paddingTop;
|
||||||
|
measure.style.paddingBottom = cs.paddingBottom;
|
||||||
|
measure.style.paddingLeft = cs.paddingLeft;
|
||||||
|
measure.style.paddingRight = cs.paddingRight;
|
||||||
|
|
||||||
|
measure.style.borderTopWidth = cs.borderTopWidth;
|
||||||
|
measure.style.borderBottomWidth = cs.borderBottomWidth;
|
||||||
|
measure.style.borderLeftWidth = cs.borderLeftWidth;
|
||||||
|
measure.style.borderRightWidth = cs.borderRightWidth;
|
||||||
|
measure.style.borderTopStyle = cs.borderTopStyle;
|
||||||
|
measure.style.borderBottomStyle = cs.borderBottomStyle;
|
||||||
|
measure.style.borderLeftStyle = cs.borderLeftStyle;
|
||||||
|
measure.style.borderRightStyle = cs.borderRightStyle;
|
||||||
|
measure.style.borderTopColor = cs.borderTopColor;
|
||||||
|
measure.style.borderBottomColor = cs.borderBottomColor;
|
||||||
|
measure.style.borderLeftColor = cs.borderLeftColor;
|
||||||
|
measure.style.borderRightColor = cs.borderRightColor;
|
||||||
|
|
||||||
|
measure.style.borderRadius = cs.borderRadius;
|
||||||
|
}
|
||||||
|
|
||||||
export const TextareaAutosize = React.forwardRef<HTMLTextAreaElement, TextareaAutosizeProps>((props, forwardedRef) => {
|
export const TextareaAutosize = React.forwardRef<HTMLTextAreaElement, TextareaAutosizeProps>((props, forwardedRef) => {
|
||||||
const {minRows: minRowsProp, maxRows, style, onHeightChange, rows, onInput, ...rest} = props;
|
const {minRows: minRowsProp, maxRows, style, onHeightChange, rows, onInput, ...rest} = props;
|
||||||
|
|
||||||
const resolvedRows = rows ?? 1;
|
const resolvedRows = rows ?? 1;
|
||||||
const minRows = minRowsProp ?? (typeof resolvedRows === 'number' ? resolvedRows : undefined);
|
const minRows = minRowsProp ?? (typeof resolvedRows === 'number' ? resolvedRows : undefined);
|
||||||
|
|
||||||
|
const nativeFieldSizing = supportsFieldSizingContent();
|
||||||
|
|
||||||
const elRef = React.useRef<HTMLTextAreaElement | null>(null);
|
const elRef = React.useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
const measureRef = React.useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
const onHeightChangeRef = React.useRef(onHeightChange);
|
const onHeightChangeRef = React.useRef(onHeightChange);
|
||||||
const lastWidthRef = React.useRef<number | null>(null);
|
const lastWidthRef = React.useRef<number | null>(null);
|
||||||
const lastHeightRef = React.useRef<number | null>(null);
|
const lastEmittedHeightRef = React.useRef<number | null>(null);
|
||||||
const resizeScheduledRef = React.useRef(false);
|
const resizeScheduledRef = React.useRef(false);
|
||||||
|
|
||||||
const setRef = React.useCallback(
|
const setRef = React.useCallback(
|
||||||
@ -76,49 +156,84 @@ export const TextareaAutosize = React.forwardRef<HTMLTextAreaElement, TextareaAu
|
|||||||
onHeightChangeRef.current = onHeightChange;
|
onHeightChangeRef.current = onHeightChange;
|
||||||
}, [onHeightChange]);
|
}, [onHeightChange]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (nativeFieldSizing) return;
|
||||||
|
const measure = ensureMeasureEl();
|
||||||
|
measureRef.current = measure;
|
||||||
|
return () => {
|
||||||
|
measureRef.current?.remove();
|
||||||
|
measureRef.current = null;
|
||||||
|
};
|
||||||
|
}, [nativeFieldSizing]);
|
||||||
|
|
||||||
|
const emitHeightIfChanged = React.useCallback(() => {
|
||||||
|
const el = elRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const cs = window.getComputedStyle(el);
|
||||||
|
const lineHeight = getLineHeight(cs);
|
||||||
|
const height = Math.round(el.getBoundingClientRect().height);
|
||||||
|
|
||||||
|
if (lastEmittedHeightRef.current !== height) {
|
||||||
|
lastEmittedHeightRef.current = height;
|
||||||
|
onHeightChangeRef.current?.(height, {rowHeight: lineHeight});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
const el = elRef.current;
|
const el = elRef.current;
|
||||||
if (!el || (minRows == null && maxRows == null)) return;
|
if (!el || (minRows == null && maxRows == null)) return;
|
||||||
|
|
||||||
const {minHeight, maxHeight} = computeRowConstraints(el, minRows, maxRows);
|
const {minHeight, maxHeight} = computeRowConstraints(el, minRows, maxRows);
|
||||||
|
|
||||||
if (minHeight != null) {
|
if (minHeight != null) el.style.minHeight = `${minHeight}px`;
|
||||||
el.style.minHeight = `${minHeight}px`;
|
if (maxHeight != null) el.style.maxHeight = `${maxHeight}px`;
|
||||||
}
|
|
||||||
if (maxHeight != null) {
|
|
||||||
el.style.maxHeight = `${maxHeight}px`;
|
|
||||||
}
|
|
||||||
}, [minRows, maxRows]);
|
}, [minRows, maxRows]);
|
||||||
|
|
||||||
const resize = React.useCallback(() => {
|
const resize = React.useCallback(() => {
|
||||||
const el = elRef.current;
|
const el = elRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
|
if (nativeFieldSizing) {
|
||||||
|
emitHeightIfChanged();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const measure = measureRef.current;
|
||||||
|
if (!measure) return;
|
||||||
|
|
||||||
const cs = window.getComputedStyle(el);
|
const cs = window.getComputedStyle(el);
|
||||||
const {minHeight, maxHeight, lineHeight} = computeRowConstraints(el, minRows, maxRows);
|
const {minHeight, maxHeight, lineHeight} = computeRowConstraints(el, minRows, maxRows);
|
||||||
const borderBlock = getNumber(cs.borderTopWidth) + getNumber(cs.borderBottomWidth);
|
const borderBlock = getNumber(cs.borderTopWidth) + getNumber(cs.borderBottomWidth);
|
||||||
const isBorderBox = cs.boxSizing === 'border-box';
|
const isBorderBox = cs.boxSizing === 'border-box';
|
||||||
|
|
||||||
el.style.height = 'auto';
|
syncMeasureStyles(el, measure);
|
||||||
|
|
||||||
let nextHeight = el.scrollHeight + (isBorderBox ? borderBlock : 0);
|
const measuredValue = normalizeValueForMeasurement(el.value);
|
||||||
|
if (measure.value !== measuredValue) {
|
||||||
|
measure.value = measuredValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextHeight = measure.scrollHeight + (isBorderBox ? borderBlock : 0);
|
||||||
if (minHeight != null) nextHeight = Math.max(nextHeight, minHeight);
|
if (minHeight != null) nextHeight = Math.max(nextHeight, minHeight);
|
||||||
if (maxHeight != null) nextHeight = Math.min(nextHeight, maxHeight);
|
if (maxHeight != null) nextHeight = Math.min(nextHeight, maxHeight);
|
||||||
|
|
||||||
const heightPx = `${nextHeight}px`;
|
const heightPx = `${Math.round(nextHeight)}px`;
|
||||||
if (el.style.height !== heightPx) {
|
if (el.style.height !== heightPx) {
|
||||||
el.style.height = heightPx;
|
el.style.height = heightPx;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastHeightRef.current !== nextHeight) {
|
const emittedHeight = Math.round(nextHeight);
|
||||||
lastHeightRef.current = nextHeight;
|
if (lastEmittedHeightRef.current !== emittedHeight) {
|
||||||
onHeightChangeRef.current?.(nextHeight, {rowHeight: lineHeight});
|
lastEmittedHeightRef.current = emittedHeight;
|
||||||
|
onHeightChangeRef.current?.(emittedHeight, {rowHeight: lineHeight});
|
||||||
}
|
}
|
||||||
}, [maxRows, minRows]);
|
}, [emitHeightIfChanged, maxRows, minRows, nativeFieldSizing]);
|
||||||
|
|
||||||
const scheduleResize = React.useCallback(() => {
|
const scheduleResize = React.useCallback(() => {
|
||||||
if (resizeScheduledRef.current) return;
|
if (resizeScheduledRef.current) return;
|
||||||
resizeScheduledRef.current = true;
|
resizeScheduledRef.current = true;
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
resizeScheduledRef.current = false;
|
resizeScheduledRef.current = false;
|
||||||
resize();
|
resize();
|
||||||
@ -133,16 +248,22 @@ export const TextareaAutosize = React.forwardRef<HTMLTextAreaElement, TextareaAu
|
|||||||
const entry = entries[0];
|
const entry = entries[0];
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
|
|
||||||
const width = entry.borderBoxSize?.[0]?.inlineSize ?? el.getBoundingClientRect().width;
|
const width = (entry as any).borderBoxSize?.[0]?.inlineSize ?? el.getBoundingClientRect().width;
|
||||||
|
|
||||||
if (width !== lastWidthRef.current) {
|
if (width !== lastWidthRef.current) {
|
||||||
lastWidthRef.current = width;
|
lastWidthRef.current = width;
|
||||||
scheduleResize();
|
scheduleResize();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nativeFieldSizing) {
|
||||||
|
emitHeightIfChanged();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ro.observe(el);
|
ro.observe(el);
|
||||||
return () => ro.disconnect();
|
return () => ro.disconnect();
|
||||||
}, [scheduleResize]);
|
}, [emitHeightIfChanged, nativeFieldSizing, scheduleResize]);
|
||||||
|
|
||||||
const computedStyle = React.useMemo(
|
const computedStyle = React.useMemo(
|
||||||
(): React.CSSProperties => ({
|
(): React.CSSProperties => ({
|
||||||
@ -154,15 +275,15 @@ export const TextareaAutosize = React.forwardRef<HTMLTextAreaElement, TextareaAu
|
|||||||
|
|
||||||
const handleInput = React.useCallback(
|
const handleInput = React.useCallback(
|
||||||
(event: React.FormEvent<HTMLTextAreaElement>) => {
|
(event: React.FormEvent<HTMLTextAreaElement>) => {
|
||||||
resize();
|
scheduleResize();
|
||||||
onInput?.(event);
|
onInput?.(event);
|
||||||
},
|
},
|
||||||
[onInput, resize],
|
[onInput, scheduleResize],
|
||||||
);
|
);
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
resize();
|
scheduleResize();
|
||||||
}, [resize, props.value, props.defaultValue, rows]);
|
}, [scheduleResize, props.value, props.defaultValue, rows, minRows, maxRows, nativeFieldSizing]);
|
||||||
|
|
||||||
return <textarea {...rest} ref={setRef} rows={resolvedRows} style={computedStyle} onInput={handleInput} />;
|
return <textarea {...rest} ref={setRef} rows={resolvedRows} style={computedStyle} onInput={handleInput} />;
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user