mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-11 02:13:48 +00:00
perf(cuesheet): lazily mount text-cell editors (Exp A) — validated win
Render a lightweight, focusable TextLikeInput for cue/title/note/custom cells and only mount the real input/textarea editor (with reactive-input hooks and autosize) on click/focus, mirroring the existing time/duration cells. Measured baseline vs Exp A at CPU 4x (median of 3, ~1140 row mounts): - longFrames 201 -> 127 (-37%), maxFrameMs 517 -> 417 (-19%) - longtask total 58.9s -> 30.7s (-48%), EventRow.mount avg 179 -> 146ms (-18%) - cell.autosize and cell.reactiveInputInit eliminated from the scroll path - per-row re-renders 4648 -> 3048 (-34%): stable display height, no virtuoso churn (the opposite of the reverted lazy-autosize Exp B, which doubled re-renders) Editing verified end-to-end: click mounts+focuses the editor, edits persist, editor unmounts back to the display on commit. TextLikeInput now fills its cell so the whole cell is clickable to edit.
This commit is contained in:
+86
@@ -0,0 +1,86 @@
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import MultiLineCell from './MultiLineCell';
|
||||
import SingleLineCell from './SingleLineCell';
|
||||
import TextLikeInput from './TextLikeInput';
|
||||
|
||||
interface EditableCellProps {
|
||||
initialValue: string;
|
||||
multiline?: boolean;
|
||||
fieldId?: string;
|
||||
fieldLabel?: string;
|
||||
handleUpdate: (newValue: string) => void;
|
||||
}
|
||||
|
||||
interface FocusableEditor {
|
||||
focus: () => void;
|
||||
select?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily mounts the text editor for a cell.
|
||||
*
|
||||
* Mounting an `<input>`/`<textarea>` editor (with its reactive-input hooks and autosize) for every
|
||||
* cell is expensive when many rows mount at once during virtualised scroll. While the cell is not
|
||||
* being edited we render a lightweight, focusable display element and only mount the real editor
|
||||
* when the user clicks/focuses the cell — mirroring how the time/duration cells already behave.
|
||||
*/
|
||||
function EditableCell({ initialValue, multiline, fieldId, fieldLabel, handleUpdate }: EditableCellProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const editorRef = useRef<FocusableEditor | null>(null);
|
||||
|
||||
// focus the editor once it mounts on entering edit mode
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
editorRef.current?.focus();
|
||||
editorRef.current?.select?.();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const enterEdit = useCallback(() => setIsEditing(true), []);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(newValue: string) => {
|
||||
setIsEditing(false);
|
||||
handleUpdate(newValue);
|
||||
},
|
||||
[handleUpdate],
|
||||
);
|
||||
|
||||
const onCancel = useCallback(() => setIsEditing(false), []);
|
||||
|
||||
if (!isEditing) {
|
||||
return (
|
||||
<TextLikeInput
|
||||
onClick={enterEdit}
|
||||
onFocus={enterEdit}
|
||||
multiline={multiline}
|
||||
aria-label={fieldLabel ? `${fieldLabel} cell` : undefined}
|
||||
>
|
||||
{initialValue}
|
||||
</TextLikeInput>
|
||||
);
|
||||
}
|
||||
|
||||
return multiline ? (
|
||||
<MultiLineCell
|
||||
ref={editorRef}
|
||||
initialValue={initialValue}
|
||||
fieldId={fieldId}
|
||||
fieldLabel={fieldLabel}
|
||||
handleUpdate={onSubmit}
|
||||
handleCancelUpdate={onCancel}
|
||||
/>
|
||||
) : (
|
||||
<SingleLineCell
|
||||
ref={editorRef}
|
||||
initialValue={initialValue}
|
||||
fieldId={fieldId}
|
||||
fieldLabel={fieldLabel}
|
||||
handleUpdate={onSubmit}
|
||||
handleCancelUpdate={onCancel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(EditableCell);
|
||||
+24
-5
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useRef } from 'react';
|
||||
import { forwardRef, memo, useCallback, useImperativeHandle, useRef } from 'react';
|
||||
|
||||
import { AutoTextarea } from '../../../../common/components/input/auto-textarea/AutoTextarea';
|
||||
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
|
||||
@@ -8,19 +8,36 @@ interface MultiLineCellProps {
|
||||
fieldId?: string;
|
||||
fieldLabel?: string;
|
||||
handleUpdate: (newValue: string) => void;
|
||||
handleCancelUpdate?: () => void;
|
||||
}
|
||||
|
||||
export default memo(MultiLineCell);
|
||||
|
||||
function MultiLineCell({ initialValue, fieldId, fieldLabel, handleUpdate }: MultiLineCellProps) {
|
||||
const MultiLineCell = forwardRef(function MultiLineCell(
|
||||
{ initialValue, fieldId, fieldLabel, handleUpdate, handleCancelUpdate }: MultiLineCellProps,
|
||||
inputRef,
|
||||
) {
|
||||
const ref = useRef<HTMLTextAreaElement | null>(null);
|
||||
const submitCallback = useCallback((newValue: string) => handleUpdate(newValue), [handleUpdate]);
|
||||
|
||||
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, {
|
||||
submitOnCtrlEnter: true,
|
||||
allowKeyboardNavigation: true,
|
||||
onCancelUpdate: handleCancelUpdate,
|
||||
});
|
||||
|
||||
// expose focus to the parent so the editor can be focused when mounted on demand
|
||||
useImperativeHandle(
|
||||
inputRef,
|
||||
() => ({
|
||||
focus() {
|
||||
ref.current?.focus();
|
||||
},
|
||||
select() {
|
||||
ref.current?.select();
|
||||
},
|
||||
}),
|
||||
[ref],
|
||||
);
|
||||
|
||||
return (
|
||||
<AutoTextarea
|
||||
inputref={ref}
|
||||
@@ -36,4 +53,6 @@ function MultiLineCell({ initialValue, fieldId, fieldLabel, handleUpdate }: Mult
|
||||
aria-label={fieldLabel ? `${fieldLabel} editor` : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default memo(MultiLineCell);
|
||||
|
||||
+13
-1
@@ -1,6 +1,8 @@
|
||||
/* element matching input styles */
|
||||
.textInput {
|
||||
height: 2rem;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 2rem;
|
||||
background-color: transparent;
|
||||
border-radius: $component-border-radius-md;
|
||||
text-wrap: nowrap;
|
||||
@@ -32,4 +34,14 @@
|
||||
background-color: $gray-1100;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
&.multiline {
|
||||
height: auto;
|
||||
min-height: 2rem;
|
||||
text-wrap: wrap;
|
||||
white-space: break-spaces;
|
||||
overflow: hidden;
|
||||
align-items: flex-start;
|
||||
padding-top: 0.25em;
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -8,11 +8,12 @@ interface TextLikeInputProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
offset?: 'over' | 'under' | 'muted' | null;
|
||||
muted?: boolean;
|
||||
disabled?: boolean;
|
||||
multiline?: boolean;
|
||||
}
|
||||
|
||||
const TextLikeInput = forwardRef(
|
||||
(
|
||||
{ offset, muted, disabled, children, className, ...elementProps }: PropsWithChildren<TextLikeInputProps>,
|
||||
{ offset, muted, disabled, multiline, children, className, ...elementProps }: PropsWithChildren<TextLikeInputProps>,
|
||||
textRef,
|
||||
) => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
@@ -21,6 +22,7 @@ const TextLikeInput = forwardRef(
|
||||
offset && style[offset],
|
||||
muted && style.muted,
|
||||
disabled && style.disabled,
|
||||
multiline && style.multiline,
|
||||
className,
|
||||
]);
|
||||
|
||||
|
||||
+6
-5
@@ -9,12 +9,11 @@ import { formatDuration, formatTime } from '../../../../common/utils/time';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
import { getCuesheetColumnAccessPolicy } from '../../cuesheet.policies';
|
||||
import DurationInput from './DurationInput';
|
||||
import EditableCell from './EditableCell';
|
||||
import EditableImage from './EditableImage';
|
||||
import FlagCell from './FlagCell';
|
||||
import GhostedText from './GhostedText';
|
||||
import MultiLineCell from './MultiLineCell';
|
||||
import MutedText from './MutedText';
|
||||
import SingleLineCell from './SingleLineCell';
|
||||
import TimeInput from './TimeInput';
|
||||
|
||||
function getColumnLabel(column: CellContext<ExtendedEntry, unknown>['column']): string {
|
||||
@@ -151,7 +150,8 @@ function MakeMultiLineField({ row, column, table }: CellContext<ExtendedEntry, u
|
||||
}
|
||||
|
||||
return (
|
||||
<MultiLineCell
|
||||
<EditableCell
|
||||
multiline
|
||||
initialValue={initialValue as string}
|
||||
fieldId={column.id}
|
||||
fieldLabel={getColumnLabel(column)}
|
||||
@@ -198,7 +198,7 @@ function MakeSingleLineField({ row, column, table }: CellContext<ExtendedEntry,
|
||||
}
|
||||
|
||||
return (
|
||||
<SingleLineCell
|
||||
<EditableCell
|
||||
initialValue={initialValue as string}
|
||||
fieldId={column.id}
|
||||
fieldLabel={getColumnLabel(column)}
|
||||
@@ -238,7 +238,8 @@ function MakeCustomField({ row, column, table }: CellContext<ExtendedEntry, unkn
|
||||
}
|
||||
|
||||
return (
|
||||
<MultiLineCell
|
||||
<EditableCell
|
||||
multiline
|
||||
initialValue={initialValue}
|
||||
fieldId={column.id}
|
||||
fieldLabel={getColumnLabel(column)}
|
||||
|
||||
Reference in New Issue
Block a user