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
|
## 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,
|
- `submit/buildOutgoingMessage.ts` flattens queued messages, the composer text,
|
||||||
inline comments and context into OpenCode's one-primary-plus-parts shape. The
|
inline comments and context into OpenCode's one-primary-plus-parts shape. The
|
||||||
oldest queued message becomes primary; **inline comments attach to the last
|
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 { composerLanguage, setLanguageContext } from './composerLanguage';
|
||||||
import type { ComposerEditorViewStore } from './viewStore';
|
import type { ComposerEditorViewStore } from './viewStore';
|
||||||
import { composerEditorTheme, composerNativeSelectionExtension } from './theme';
|
import { composerEditorTheme, composerNativeSelectionExtension } from './theme';
|
||||||
|
import { handleComposerHostMouseDown } from './hostMouseDown';
|
||||||
|
|
||||||
export interface ComposerSelection {
|
export interface ComposerSelection {
|
||||||
start: number;
|
start: number;
|
||||||
@@ -417,17 +418,7 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
|
|||||||
* extend into the padding, so the click has to be forwarded.
|
* extend into the padding, so the click has to be forwarded.
|
||||||
*/
|
*/
|
||||||
const handleHostMouseDown = React.useCallback((event: React.MouseEvent) => {
|
const handleHostMouseDown = React.useCallback((event: React.MouseEvent) => {
|
||||||
const view = viewRef.current;
|
handleComposerHostMouseDown(viewRef.current, event);
|
||||||
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();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
React.useImperativeHandle(ref, (): ComposerEditorHandle => ({
|
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