Skip to content

Commit ec3d516

Browse files
committed
fix(chart-playground-web): keep editor scroll and caret aligned with visible
text The Editor's own root had a fixed height + overflow, which let the invisible textarea and the highlighted <pre> it overlays drift out of sync once content overflowed — causing unreachable scroll, misplaced caret, and wrong text selection. Move the fixed height/scroll to the outer wrapper and let the Editor's root grow with its content instead.
1 parent b4934c3 commit ec3d516

2 files changed

Lines changed: 89 additions & 7 deletions

File tree

packages/pluggableWidgets/chart-playground-web/src/components/CodeEditor.tsx

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import hljs from "highlight.js/lib/core";
22
import json from "highlight.js/lib/languages/json";
3-
import { ReactElement } from "react";
3+
import { ClipboardEvent, ReactElement, useRef } from "react";
44
import Editor from "react-simple-code-editor";
55
import "highlight.js/styles/atom-one-light.css";
66

@@ -36,9 +36,37 @@ function jsonError(code: string): string | null {
3636

3737
export function CodeEditor(props: CodeEditorProps): ReactElement {
3838
const error = jsonError(props.value);
39+
const containerRef = useRef<HTMLDivElement>(null);
40+
41+
const onPaste = (event: ClipboardEvent<HTMLDivElement>): void => {
42+
const textarea = event.target;
43+
if (!(textarea instanceof HTMLTextAreaElement)) {
44+
return;
45+
}
46+
const replacesWholeValue = textarea.selectionStart === 0 && textarea.selectionEnd === textarea.value.length;
47+
if (!replacesWholeValue) {
48+
return;
49+
}
50+
// Pasting over the entire value lands the caret (and scroll position) at the end of
51+
// the pasted text, same as any textarea. For a config the user is about to read
52+
// top-to-bottom, jump back to the start instead.
53+
requestAnimationFrame(() => {
54+
textarea.setSelectionRange(0, 0);
55+
textarea.scrollTop = 0;
56+
const wrapper = containerRef.current;
57+
if (wrapper) {
58+
wrapper.scrollTop = 0;
59+
}
60+
});
61+
};
3962

4063
return (
41-
<div className="widget-charts-playground-code-editor">
64+
<div
65+
ref={containerRef}
66+
className="widget-charts-playground-code-editor"
67+
style={{ height: props.height ?? "200px" }}
68+
onPaste={onPaste}
69+
>
4270
{error && (
4371
<div className="widget-charts-playground-code-editor-error" role="alert">
4472
{error}
@@ -54,7 +82,11 @@ export function CodeEditor(props: CodeEditorProps): ReactElement {
5482
insertSpaces
5583
ignoreTabKey={false}
5684
spellCheck={false}
57-
style={{ height: props.height ?? "200px", width: "100%", fontFamily: "monospace" }}
85+
style={{
86+
width: "100%",
87+
minHeight: "100%",
88+
fontFamily: "monospace"
89+
}}
5890
/>
5991
</div>
6092
);

packages/pluggableWidgets/chart-playground-web/src/components/__tests__/CodeEditor.spec.tsx

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,61 @@ describe("CodeEditor", () => {
4848
});
4949

5050
it("honors the height prop (unchanged prop contract)", () => {
51-
render(<CodeEditor value="{}" height="300px" />);
51+
const { container } = render(<CodeEditor value="{}" height="300px" />);
52+
53+
// Height is applied to the scrollable wrapper, not react-simple-code-editor's own
54+
// root — that root must grow with content so its overlaid textarea and highlighted
55+
// <pre> stay pixel-aligned (a fixed height there desyncs cursor/selection from the
56+
// visible text once content overflows).
57+
const wrapper = container.querySelector(".widget-charts-playground-code-editor") as HTMLElement;
58+
expect(wrapper.style.height).toBe("300px");
59+
});
60+
61+
it("moves the caret and scroll position to the start after a paste that replaces the whole value", () => {
62+
render(<CodeEditor value='{"a":1}' onChange={jest.fn()} />);
63+
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
64+
65+
// Selecting everything before pasting is what native textareas treat as
66+
// "replace the whole value".
67+
textarea.setSelectionRange(0, textarea.value.length);
68+
let rafCallback: (() => void) | undefined;
69+
const rafSpy = jest.spyOn(window, "requestAnimationFrame").mockImplementation(cb => {
70+
rafCallback = cb as () => void;
71+
return 0;
72+
});
73+
74+
fireEvent.paste(textarea);
75+
76+
// The paste handler reads the pre-paste selection and schedules a correction for
77+
// after the browser applies the actual insertion — simulate that insertion now,
78+
// before flushing the scheduled callback.
79+
textarea.value = '{\n "a": 1,\n "b": 2\n}';
80+
textarea.selectionStart = textarea.selectionEnd = textarea.value.length;
81+
textarea.scrollTop = 40;
82+
83+
rafCallback?.();
84+
85+
expect(textarea.selectionStart).toBe(0);
86+
expect(textarea.selectionEnd).toBe(0);
87+
expect(textarea.scrollTop).toBe(0);
88+
89+
rafSpy.mockRestore();
90+
});
91+
92+
it("leaves the caret where the user pasted for a partial (non-whole-value) paste", () => {
93+
render(<CodeEditor value='{"a":1}' onChange={jest.fn()} />);
94+
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
95+
96+
// Caret sits mid-document, no selection — a partial paste, not a whole-value replace.
97+
textarea.setSelectionRange(3, 3);
98+
const rafSpy = jest.spyOn(window, "requestAnimationFrame");
99+
100+
fireEvent.paste(textarea);
101+
102+
expect(rafSpy).not.toHaveBeenCalled();
103+
expect(textarea.selectionStart).toBe(3);
52104

53-
// react-simple-code-editor applies the style prop to its container (parent of the textarea).
54-
const container = screen.getByRole("textbox").parentElement as HTMLElement;
55-
expect(container.style.height).toBe("300px");
105+
rafSpy.mockRestore();
56106
});
57107

58108
it("does not reintroduce CodeMirror (the v6.3.0 bundling break)", () => {

0 commit comments

Comments
 (0)