fix(ui): paint composer caret on first padding click (#2509)
This commit is contained in:
@@ -83,6 +83,10 @@ and the send path reading the same grammar.
|
||||
|
||||
## Ordering rules worth knowing
|
||||
|
||||
- `editor/ComposerEditor.tsx` forwards a click on the composer's padding by
|
||||
focusing the view *before* setting the selection: CodeMirror reveals its
|
||||
drawn caret through a class it only writes while applying an update, so the
|
||||
selection has to be the update that follows the focus.
|
||||
- `submit/buildOutgoingMessage.ts` flattens queued messages, the composer text,
|
||||
inline comments and context into OpenCode's one-primary-plus-parts shape. The
|
||||
oldest queued message becomes primary; **inline comments attach to the last
|
||||
|
||||
@@ -37,6 +37,7 @@ import type { ComposerLanguageContext } from '../language/tokenize';
|
||||
import { composerLanguage, setLanguageContext } from './composerLanguage';
|
||||
import type { ComposerEditorViewStore } from './viewStore';
|
||||
import { composerEditorTheme, composerNativeSelectionExtension } from './theme';
|
||||
import { handleComposerHostMouseDown } from './hostMouseDown';
|
||||
|
||||
export interface ComposerSelection {
|
||||
start: number;
|
||||
@@ -417,17 +418,7 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
|
||||
* extend into the padding, so the click has to be forwarded.
|
||||
*/
|
||||
const handleHostMouseDown = React.useCallback((event: React.MouseEvent) => {
|
||||
const view = viewRef.current;
|
||||
if (!view || view.state.readOnly || !view.contentDOM.isContentEditable) return;
|
||||
// A click that already landed in the text needs no help, and
|
||||
// forwarding it would break drag-selection.
|
||||
if (view.contentDOM.contains(event.target as Node)) return;
|
||||
|
||||
event.preventDefault();
|
||||
const position = view.posAtCoords({ x: event.clientX, y: event.clientY })
|
||||
?? view.state.doc.length;
|
||||
view.dispatch({ selection: { anchor: position } });
|
||||
view.focus();
|
||||
handleComposerHostMouseDown(viewRef.current, event);
|
||||
}, []);
|
||||
|
||||
React.useImperativeHandle(ref, (): ComposerEditorHandle => ({
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { EditorState, type TransactionSpec } from '@codemirror/state';
|
||||
import { drawSelection, type EditorView } from '@codemirror/view';
|
||||
|
||||
import { handleComposerHostMouseDown } from '../hostMouseDown';
|
||||
|
||||
const padding = {} as Node;
|
||||
const shell = {} as Node;
|
||||
const text = {} as Node;
|
||||
|
||||
class ComposerViewHarness {
|
||||
state = EditorState.create({ doc: 'hello', extensions: [drawSelection()] });
|
||||
contentActive = false;
|
||||
windowActive = true;
|
||||
caretPainted = false;
|
||||
prevented = false;
|
||||
position: number | null = 2;
|
||||
readonly contentDOM = {
|
||||
isContentEditable: true,
|
||||
contains: (target: Node) => target === text,
|
||||
};
|
||||
|
||||
focus(): void {
|
||||
this.windowActive = true;
|
||||
this.contentActive = true;
|
||||
}
|
||||
|
||||
dispatch(spec: TransactionSpec): void {
|
||||
this.state = this.state.update(spec).state;
|
||||
this.caretPainted = this.windowActive && this.contentActive;
|
||||
}
|
||||
|
||||
posAtCoords(): number | null {
|
||||
return this.position;
|
||||
}
|
||||
|
||||
mouseDown(target: Node): void {
|
||||
handleComposerHostMouseDown(this as unknown as EditorView, {
|
||||
target,
|
||||
clientX: 10,
|
||||
clientY: 20,
|
||||
preventDefault: () => { this.prevented = true; },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe('composer host mouse down', () => {
|
||||
test('focuses before the first padding selection update paints the caret', () => {
|
||||
const view = new ComposerViewHarness();
|
||||
|
||||
view.mouseDown(padding);
|
||||
|
||||
expect(view.prevented).toBe(true);
|
||||
expect(view.contentActive).toBe(true);
|
||||
expect(view.state.selection.main.head).toBe(2);
|
||||
expect(view.caretPainted).toBe(true);
|
||||
});
|
||||
|
||||
test('repaints after window reactivation even when focus bookkeeping is stale', () => {
|
||||
const view = new ComposerViewHarness();
|
||||
// The content remains active while CodeMirror's last notified focus is
|
||||
// stale; reactivating the window does not itself repaint drawSelection.
|
||||
view.contentActive = true;
|
||||
view.windowActive = false;
|
||||
view.caretPainted = false;
|
||||
|
||||
view.mouseDown(shell);
|
||||
|
||||
expect(view.windowActive).toBe(true);
|
||||
expect(view.caretPainted).toBe(true);
|
||||
});
|
||||
|
||||
test('falls back to the document end when padding has no mapped position', () => {
|
||||
const view = new ComposerViewHarness();
|
||||
view.position = null;
|
||||
|
||||
view.mouseDown(padding);
|
||||
|
||||
expect(view.state.selection.main.head).toBe(5);
|
||||
});
|
||||
|
||||
test('leaves native text selection and read-only editors alone', () => {
|
||||
const textView = new ComposerViewHarness();
|
||||
textView.mouseDown(text);
|
||||
expect(textView.prevented).toBe(false);
|
||||
expect(textView.contentActive).toBe(false);
|
||||
|
||||
const readOnlyView = new ComposerViewHarness();
|
||||
readOnlyView.state = EditorState.create({
|
||||
doc: 'hello',
|
||||
extensions: [EditorState.readOnly.of(true), drawSelection()],
|
||||
});
|
||||
readOnlyView.mouseDown(padding);
|
||||
expect(readOnlyView.prevented).toBe(false);
|
||||
expect(readOnlyView.contentActive).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
type ComposerHostMouseDownEvent = Pick<
|
||||
MouseEvent,
|
||||
'target' | 'clientX' | 'clientY' | 'preventDefault'
|
||||
>;
|
||||
|
||||
export function handleComposerHostMouseDown(
|
||||
view: EditorView | null,
|
||||
event: ComposerHostMouseDownEvent,
|
||||
): void {
|
||||
if (!view || view.state.readOnly || !view.contentDOM.isContentEditable) return;
|
||||
// A click that already landed in the text needs no help, and
|
||||
// forwarding it would break drag-selection.
|
||||
if (view.contentDOM.contains(event.target as Node)) return;
|
||||
|
||||
event.preventDefault();
|
||||
const position = view.posAtCoords({ x: event.clientX, y: event.clientY })
|
||||
?? view.state.doc.length;
|
||||
// Focus before dispatching so CodeMirror updates the drawn caret's
|
||||
// visibility while applying the selection update.
|
||||
view.focus();
|
||||
view.dispatch({ selection: { anchor: position } });
|
||||
}
|
||||
Reference in New Issue
Block a user