fix: retry giveFocus on next animation frame for newly created blocks (#3100)

Fixes #2926

## Problem

Keyboard scrolling (arrow keys) does not work in newly opened blocks
until the user mouse-clicks. This is a race condition in BlockFull's
focus handling.

When `isFocused` becomes true for a newly created block,
`setFocusTarget()` calls `viewModel.giveFocus()`. But the view's DOM
element (terminal, Monaco editor, webview) may not be mounted yet, so
`giveFocus()` returns false and focus falls back to the hidden dummy
`<input>` element — which cannot handle arrow key scrolling.

## Fix

After falling back to the dummy focus element, schedule a
`requestAnimationFrame` callback that retries `viewModel.giveFocus()`.
This gives React one more frame to flush pending renders and mount the
view's DOM, so focus transfers to the real element once it's ready.

## Test Plan

- Open a new terminal block — verify arrow keys scroll immediately
without clicking
- Open a file preview — verify arrow keys scroll the file content  
- Open a webview — verify keyboard scrolling works
- Switch focus between blocks using keyboard shortcuts — verify
scrolling works in each
- `npx tsc --noEmit` passes clean

---------

Signed-off-by: majiayu000 <1835304752@qq.com>
This commit is contained in:
lif 2026-03-25 00:57:02 +08:00 committed by GitHub
parent 4805c598ca
commit 9ed86e9e18
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -144,6 +144,7 @@ const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => {
const focusElemRef = useRef<HTMLInputElement>(null);
const blockRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const pendingFocusRafRef = useRef<number | null>(null);
const [blockClicked, setBlockClicked] = useState(false);
const blockView = useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "view")) ?? "";
const isFocused = useAtomValue(nodeModel.isFocused);
@ -156,6 +157,14 @@ const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => {
const innerRect = useDebouncedNodeInnerRect(nodeModel);
const noPadding = useAtomValueSafe(viewModel.noPadding);
useEffect(() => {
return () => {
if (pendingFocusRafRef.current != null) {
cancelAnimationFrame(pendingFocusRafRef.current);
}
};
}, []);
useLayoutEffect(() => {
setBlockClicked(isFocused);
}, [isFocused]);
@ -221,11 +230,21 @@ const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => {
);
const setFocusTarget = useCallback(() => {
if (pendingFocusRafRef.current != null) {
cancelAnimationFrame(pendingFocusRafRef.current);
pendingFocusRafRef.current = null;
}
const ok = viewModel?.giveFocus?.();
if (ok) {
return;
}
focusElemRef.current?.focus({ preventScroll: true });
pendingFocusRafRef.current = requestAnimationFrame(() => {
pendingFocusRafRef.current = null;
if (blockRef.current?.contains(document.activeElement)) {
viewModel?.giveFocus?.();
}
});
}, [viewModel]);
const focusFromPointerEnter = useCallback(