fix(ui): use CodeMirror selection handles on iOS
This commit is contained in:
@@ -61,20 +61,46 @@ question of design, not of feasibility.
|
||||
Selection rendering: every device runs CodeMirror's `drawSelection()` — it
|
||||
keeps typing on the drawn-selection code path, and removing it makes
|
||||
CodeMirror enforce cursor association on the native selection, which iOS
|
||||
answers with severe input lag. Every device also layers
|
||||
`composerNativeSelectionExtension` (`editor/theme.ts`) on top: it re-shows
|
||||
answers with severe input lag. **That much is not platform-specific and must
|
||||
not be undone.** What differs is who paints the selection, and
|
||||
`composerSelectionExtension` (`editor/theme.ts`) picks that per platform.
|
||||
|
||||
When CodeMirror 6.43.9's iOS predicate does not match,
|
||||
`composerNativeSelectionExtension` layers over `drawSelection()`: it re-shows
|
||||
the native selection, and — only while a range is selected — the native caret,
|
||||
hiding the painted layers those replace. The native selection is the one that
|
||||
shows for two reasons: the painted layer sits behind the content, so tokens
|
||||
with their own background (inline code, fences) cover it completely; and
|
||||
iOS's selection drag handles attach to the visible native selection and take
|
||||
their colour from the caret, so a transparent caret means invisible handles.
|
||||
The range-only caret scoping is load-bearing — a native caret visible while
|
||||
typing makes WebKit re-render its caret UI after every keystroke, felt as
|
||||
severe input lag. The selection tint comes from `--primary`, not the selection
|
||||
token:
|
||||
themes define `--interactive-selection` with its own alpha, so a translucent
|
||||
mix of it is nearly invisible.
|
||||
with their own background (inline code, fences) cover it completely; and the
|
||||
platform's selection drag handles attach to the visible native selection and
|
||||
take their colour from the caret, so a transparent caret means invisible
|
||||
handles. The range-only caret scoping is load-bearing — a native caret visible
|
||||
while typing makes the browser re-render its caret UI after every keystroke,
|
||||
felt as severe input lag.
|
||||
|
||||
When CodeMirror 6.43.9's exact iOS predicate matches,
|
||||
`composerIOSSelectionExtension` leaves selection-handle geometry and appearance
|
||||
to CodeMirror. CodeMirror puts the handles in `.cm-selectionLayer`, normally at
|
||||
`z-index: -1`; the extension raises that layer above the content so opaque
|
||||
token backgrounds cannot cover them, and leaves it transparent to touch.
|
||||
The handle dots extend 8px past their range; matching scroller padding and
|
||||
negative margin expand the clip area without moving the text or changing the
|
||||
composer height. iOS still paints its taller system selection overlay even
|
||||
when CSS makes `::selection` transparent. The extension therefore suppresses
|
||||
CodeMirror's synthetic selection rectangles on iOS while leaving its handles,
|
||||
cursor path and `nativeSelectionHidden` facet active. Otherwise the grey system
|
||||
highlight and themed rectangle overlap with visibly different heights.
|
||||
Do not add a second custom layer or custom handles here: overlapping translucent
|
||||
rectangles make selection darker at their seams and imitated handles drift from
|
||||
the geometry WebKit actually manipulates. What iOS avoids is installing the
|
||||
native-selection workaround above: explicitly restoring native paint and caret
|
||||
makes WebKit re-measure them after every decoration redraw, and the composer
|
||||
rebuilds every decoration on every keystroke. That cost is felt worst during
|
||||
IME composition.
|
||||
|
||||
The non-iOS native selection tint comes from `--primary`, not the selection
|
||||
token: themes define `--interactive-selection` with its own alpha, so mixing it
|
||||
with transparent again is nearly invisible. The iOS system overlay owns its
|
||||
visible selection fill.
|
||||
|
||||
`composerLanguage.ts` retokenizes the whole document on every change. The
|
||||
composer holds a prompt, not a source file: it is short enough that a full pass
|
||||
|
||||
@@ -36,7 +36,7 @@ import { cn } from '@/lib/utils';
|
||||
import type { ComposerLanguageContext } from '../language/tokenize';
|
||||
import { composerLanguage, setLanguageContext } from './composerLanguage';
|
||||
import type { ComposerEditorViewStore } from './viewStore';
|
||||
import { composerEditorTheme, composerNativeSelectionExtension } from './theme';
|
||||
import { composerEditorTheme, composerSelectionExtension } from './theme';
|
||||
import { handleComposerHostMouseDown } from './hostMouseDown';
|
||||
|
||||
export interface ComposerSelection {
|
||||
@@ -234,14 +234,13 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
|
||||
doc: handlersRef.current.value,
|
||||
extensions: [
|
||||
history(),
|
||||
// `drawSelection()` must stay even though the native
|
||||
// selection is what actually shows (see the theme's
|
||||
// comment on `composerNativeSelectionExtension`):
|
||||
// removing it makes CodeMirror enforce cursor
|
||||
// association on the native selection, which iOS
|
||||
// answers with severe input lag.
|
||||
// `drawSelection()` must stay on every platform.
|
||||
// `composerSelectionExtension()` changes only who
|
||||
// paints the selection; removing `drawSelection()`
|
||||
// makes CodeMirror enforce cursor association on the
|
||||
// native selection, which iOS answers with severe lag.
|
||||
drawSelection(),
|
||||
composerNativeSelectionExtension,
|
||||
composerSelectionExtension(),
|
||||
EditorView.lineWrapping,
|
||||
// Highest precedence: the composer's own keys must win
|
||||
// over CodeMirror's defaults (Enter sends, ArrowUp
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { EditorState, type Extension } from '@codemirror/state';
|
||||
|
||||
import {
|
||||
COMPOSER_EDITOR_THEME_SPEC,
|
||||
IOS_SELECTION_THEME_SPEC,
|
||||
NATIVE_SELECTION_THEME_SPEC,
|
||||
composerEditorTheme,
|
||||
composerIOSSelectionExtension,
|
||||
composerNativeSelectionExtension,
|
||||
composerSelectionExtension,
|
||||
isCodeMirrorIOSNavigator,
|
||||
} from '../theme';
|
||||
|
||||
const selectors = Object.keys(COMPOSER_EDITOR_THEME_SPEC);
|
||||
const declarations = JSON.stringify(COMPOSER_EDITOR_THEME_SPEC);
|
||||
|
||||
function installationError(extension: Extension): string | null {
|
||||
try {
|
||||
EditorState.create({ extensions: [extension] });
|
||||
return null;
|
||||
} catch (error) {
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
|
||||
describe('composerEditorTheme', () => {
|
||||
/**
|
||||
* EditorView.theme compiles its selectors when this module is imported and
|
||||
@@ -20,13 +33,7 @@ describe('composerEditorTheme', () => {
|
||||
* surfaces only in the running app, where it takes the composer down.
|
||||
*/
|
||||
test('its selectors compile and the theme can be installed', () => {
|
||||
let failure: unknown = null;
|
||||
try {
|
||||
EditorState.create({ extensions: [composerEditorTheme] });
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
expect(failure).toBeNull();
|
||||
expect(installationError(composerEditorTheme)).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -101,6 +108,10 @@ describe('composerEditorTheme', () => {
|
||||
expect(rule.background.includes('transparent')).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('the common theme does not re-show the native selection', () => {
|
||||
expect(selectors.some((selector) => selector.includes('::selection'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('composerNativeSelectionTheme', () => {
|
||||
@@ -108,21 +119,16 @@ describe('composerNativeSelectionTheme', () => {
|
||||
const nativeDeclarations = JSON.stringify(NATIVE_SELECTION_THEME_SPEC);
|
||||
|
||||
/**
|
||||
* Every device layers this over `drawSelection()`: the native selection
|
||||
* paints over token backgrounds (the painted layer is hidden behind them)
|
||||
* and iOS attaches its selection handles to it. `drawSelection()` must
|
||||
* NOT be removed for that: without it CodeMirror starts enforcing cursor
|
||||
* association on the native selection while typing in wrapped text, and
|
||||
* iOS answers those programmatic selection moves with severe input lag.
|
||||
* Every device except iOS layers this over `drawSelection()`: the native
|
||||
* selection paints over token backgrounds (the painted layer is hidden
|
||||
* behind them) and the platform attaches its selection handles to it.
|
||||
* `drawSelection()` must NOT be removed for that: without it CodeMirror
|
||||
* starts enforcing cursor association on the native selection while typing
|
||||
* in wrapped text, and iOS answers those programmatic selection moves with
|
||||
* severe input lag.
|
||||
*/
|
||||
test('it compiles and can be installed', () => {
|
||||
let failure: unknown = null;
|
||||
try {
|
||||
EditorState.create({ extensions: [composerNativeSelectionExtension] });
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
expect(failure).toBeNull();
|
||||
expect(installationError(composerNativeSelectionExtension)).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -150,9 +156,9 @@ describe('composerNativeSelectionTheme', () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* iOS colours its selection drag handles from the caret colour. With
|
||||
* `drawSelection()`'s `caret-color: transparent !important` in effect the
|
||||
* handles are drawn — invisibly. The native caret must come back with
|
||||
* A platform showing native handles colours them from the caret colour.
|
||||
* With `drawSelection()`'s `caret-color: transparent !important` in effect
|
||||
* the handles are drawn — invisibly. The native caret must come back with
|
||||
* enough weight to win, and the drawn cursor layer must go so there are
|
||||
* not two carets.
|
||||
*
|
||||
@@ -195,3 +201,106 @@ describe('composerNativeSelectionTheme', () => {
|
||||
expect(tokens.filter((token) => /[A-Z]/.test(token))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('composerIOSSelectionExtension', () => {
|
||||
const layerRule = IOS_SELECTION_THEME_SPEC['& .cm-scroller > .cm-selectionLayer'];
|
||||
const scrollerRule = IOS_SELECTION_THEME_SPEC['& .cm-scroller'];
|
||||
const selectionBackgroundRule = IOS_SELECTION_THEME_SPEC['& .cm-selectionBackground'];
|
||||
|
||||
test('it compiles and can be installed', () => {
|
||||
expect(installationError(composerIOSSelectionExtension)).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* CodeMirror renders its selection layer at `z-index: -1`, behind the
|
||||
* text. Inline code and code fences have opaque backgrounds and otherwise
|
||||
* cover both the selection and the iOS handles. The base value is inline,
|
||||
* so raising it without `!important` silently does nothing.
|
||||
*/
|
||||
test('CodeMirror selection and handles are raised above token backgrounds', () => {
|
||||
expect(layerRule.zIndex).toBe('100 !important');
|
||||
});
|
||||
|
||||
/**
|
||||
* The layer now sits over the content and would intercept taps and drags
|
||||
* by default. It only paints; CodeMirror/WebKit still own the gestures.
|
||||
*/
|
||||
test('the layer does not intercept touch', () => {
|
||||
expect(layerRule.pointerEvents).toBe('none');
|
||||
});
|
||||
|
||||
/**
|
||||
* A higher z-index cannot escape overflow clipping. CodeMirror's dots
|
||||
* extend 8px past the range, so the scroller needs that much internal room;
|
||||
* the matching negative margin keeps the text and composer height fixed.
|
||||
*/
|
||||
test('the scroller reserves unclipped room for both handles', () => {
|
||||
expect(scrollerRule.paddingBlock).toBe('8px');
|
||||
expect(scrollerRule.marginBlock).toBe('-8px');
|
||||
});
|
||||
|
||||
test('the CodeMirror fill does not stack over the iOS system highlight', () => {
|
||||
expect(selectionBackgroundRule.background).toBe('transparent !important');
|
||||
});
|
||||
|
||||
/**
|
||||
* A second custom layer was visually indistinguishable from duplicate
|
||||
* native selection UI. iOS must only reposition the one layer that
|
||||
* CodeMirror already uses for both selection rectangles and handles.
|
||||
*/
|
||||
test('it does not add a second selection implementation', () => {
|
||||
expect(Object.keys(IOS_SELECTION_THEME_SPEC)).toEqual([
|
||||
'& .cm-scroller',
|
||||
'& .cm-scroller > .cm-selectionLayer',
|
||||
'& .cm-selectionBackground',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('composerSelectionExtension', () => {
|
||||
/**
|
||||
* The split is the point: iOS is the only platform that pays for a visible
|
||||
* native selection during composition, and CodeMirror 6.43.9 draws its
|
||||
* handles. Collapsing the two branches into one would
|
||||
* either restore the latency on iOS or leave every other platform without
|
||||
* discoverable range selection.
|
||||
*/
|
||||
test('the CodeMirror iOS path uses its handles; other platforms keep native selection', () => {
|
||||
expect(composerSelectionExtension(true)).toBe(composerIOSSelectionExtension);
|
||||
expect(composerSelectionExtension(false)).toBe(composerNativeSelectionExtension);
|
||||
});
|
||||
|
||||
/**
|
||||
* The composer may remove the native fallback only when CodeMirror's own
|
||||
* browser predicate enables its replacement handles. This deliberately
|
||||
* includes CodeMirror's vendor and touch thresholds rather than using a
|
||||
* broader application-level iOS heuristic.
|
||||
*/
|
||||
test('the platform predicate matches CodeMirror 6.43.9', () => {
|
||||
expect(isCodeMirrorIOSNavigator(
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6) Mobile/15E148 Safari/604.1',
|
||||
'Apple Computer, Inc.',
|
||||
5,
|
||||
)).toBe(true);
|
||||
expect(isCodeMirrorIOSNavigator(
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15)',
|
||||
'Apple Computer, Inc.',
|
||||
5,
|
||||
)).toBe(true);
|
||||
expect(isCodeMirrorIOSNavigator(
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15)',
|
||||
'Google Inc.',
|
||||
5,
|
||||
)).toBe(false);
|
||||
expect(isCodeMirrorIOSNavigator(
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15)',
|
||||
'Apple Computer, Inc.',
|
||||
0,
|
||||
)).toBe(false);
|
||||
expect(isCodeMirrorIOSNavigator(
|
||||
'Mozilla/5.0 (Windows NT 10.0; Trident/7.0; rv:11.0)',
|
||||
'Apple Computer, Inc.',
|
||||
5,
|
||||
)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* language layer emits, so the composer and the message list stay in step.
|
||||
*/
|
||||
|
||||
import type { Extension } from '@codemirror/state';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
|
||||
/**
|
||||
@@ -78,23 +79,16 @@ export const COMPOSER_EDITOR_THEME_SPEC = {
|
||||
'&.cm-editor.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
|
||||
background: 'color-mix(in srgb, var(--interactive-selection) 55%, transparent)',
|
||||
},
|
||||
// The native selection still shows through in places CodeMirror does not
|
||||
// draw over, such as the placeholder. Same colour as the native-selection
|
||||
// theme below, for the same reason: the selection token carries its own
|
||||
// alpha and reads as nearly invisible when mixed down again.
|
||||
'& ::selection': {
|
||||
background: 'color-mix(in srgb, var(--primary) 25%, transparent)',
|
||||
},
|
||||
};
|
||||
|
||||
export const composerEditorTheme = EditorView.theme(COMPOSER_EDITOR_THEME_SPEC);
|
||||
|
||||
/**
|
||||
* Every device keeps `drawSelection()` but shows the NATIVE selection through
|
||||
* it, for two independent reasons:
|
||||
* Outside CodeMirror's iOS branch, devices keep `drawSelection()` but show the
|
||||
* NATIVE selection through it, for two independent reasons:
|
||||
*
|
||||
* - iOS attaches its selection handles (the draggable pins after a
|
||||
* double-tap) to the *visible* native selection, and `drawSelection()`
|
||||
* - Their selection drag handles (the draggable pins after a double-tap)
|
||||
* attach to the *visible* native selection, and `drawSelection()`
|
||||
* hides it with `.cm-line ::selection { background: transparent
|
||||
* !important }`, so the handles never appear and range selection is
|
||||
* undiscoverable.
|
||||
@@ -103,12 +97,15 @@ export const composerEditorTheme = EditorView.theme(COMPOSER_EDITOR_THEME_SPEC);
|
||||
* the selection is invisible inside those spans. The native selection
|
||||
* paints over element backgrounds.
|
||||
*
|
||||
* Dropping `drawSelection()` entirely is NOT an option: without it CodeMirror
|
||||
* clears the `nativeSelectionHidden` facet and starts enforcing cursor
|
||||
* association on the native selection while typing in wrapped text —
|
||||
* programmatic selection moves that iOS answers with severe input lag (each
|
||||
* one also resets the keyboard's autocorrect context). Typing must stay on
|
||||
* the drawn-selection code path; only the paint changes.
|
||||
* Dropping `drawSelection()` entirely is NOT an option, on any platform:
|
||||
* without it CodeMirror clears the `nativeSelectionHidden` facet and starts
|
||||
* enforcing cursor association on the native selection while typing in
|
||||
* wrapped text — programmatic selection moves that iOS answers with severe
|
||||
* input lag (each one also resets the keyboard's autocorrect context). Typing
|
||||
* must stay on the drawn-selection code path; only the paint changes.
|
||||
*
|
||||
* CodeMirror's iOS branch does NOT use this arrangement —
|
||||
* `composerIOSSelectionExtension` below explains why.
|
||||
*
|
||||
* Both rules below fight `drawSelection()`'s own `Prec.highest` theme, so
|
||||
* they carry `!important` and one class more specificity
|
||||
@@ -155,14 +152,103 @@ export const NATIVE_SELECTION_THEME_SPEC = {
|
||||
const composerNativeSelectionTheme = EditorView.theme(NATIVE_SELECTION_THEME_SPEC);
|
||||
|
||||
/**
|
||||
* The native-selection arrangement, installed on every device: the theme
|
||||
* above plus the `.oc-native-range` marker class that scopes its caret rules
|
||||
* to the moments a range is actually selected. `editorAttributes`
|
||||
* The native-selection arrangement, installed outside CodeMirror's iOS branch:
|
||||
* the theme above plus the `.oc-native-range` marker class that scopes its
|
||||
* caret rules to the moments a range is actually selected. `editorAttributes`
|
||||
* re-evaluates on every update, so the class follows the selection with no
|
||||
* listener of its own.
|
||||
*/
|
||||
export const composerNativeSelectionExtension = [
|
||||
export const composerNativeSelectionExtension: Extension = [
|
||||
composerNativeSelectionTheme,
|
||||
EditorView.editorAttributes.of((view) =>
|
||||
view.state.selection.main.empty ? null : { class: 'oc-native-range' }),
|
||||
];
|
||||
|
||||
/**
|
||||
* When its iOS predicate matches, CodeMirror 6.43.9 draws the range handles
|
||||
* into the same layer as the selection, so CodeMirror owns both their geometry
|
||||
* and appearance.
|
||||
*
|
||||
* That layer normally renders at `z-index: -1`, behind the content. Inline
|
||||
* code and code fences have opaque backgrounds and would cover both the tint
|
||||
* and handles. Raising the one existing layer fixes that without introducing
|
||||
* a second set of rectangles or trying to imitate WebKit's controls. The
|
||||
* layer remains transparent to touch so WebKit receives selection gestures.
|
||||
*
|
||||
* What iOS avoids is the native-selection workaround above: explicitly
|
||||
* restoring the native highlight and caret makes WebKit re-measure and repaint
|
||||
* that UI after every decoration redraw. `composerLanguage.ts` rebuilds the
|
||||
* whole decoration set on every keystroke, so the cost is felt worst during
|
||||
* IME composition where each intermediate replacement pays for it. WebKit's
|
||||
* unavoidable system selection overlay remains the only visible fill.
|
||||
*/
|
||||
export const IOS_SELECTION_THEME_SPEC = {
|
||||
// The handles extend 8px above/below their range. The scroller clips them
|
||||
// at its own edge even when the layer has a high z-index, so reserve that
|
||||
// room inside the clipping box and pull the box outward by the same amount.
|
||||
// Text and composer height stay where they were; only the clip area grows.
|
||||
'& .cm-scroller': {
|
||||
marginBlock: '-8px',
|
||||
paddingBlock: '8px',
|
||||
},
|
||||
'& .cm-scroller > .cm-selectionLayer': {
|
||||
// CodeMirror writes `z-index: -1` inline. `!important` is intentional:
|
||||
// without it token backgrounds cover the selection and its handles.
|
||||
zIndex: '100 !important',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
// iOS keeps showing its taller system selection overlay even when
|
||||
// ::selection is transparent. Painting CodeMirror's themed rectangles as
|
||||
// well produces two visibly misaligned fills, so only the synthetic
|
||||
// background is suppressed. The handles in this layer remain visible.
|
||||
'& .cm-selectionBackground': {
|
||||
background: 'transparent !important',
|
||||
},
|
||||
};
|
||||
|
||||
export const composerIOSSelectionExtension: Extension =
|
||||
EditorView.theme(IOS_SELECTION_THEME_SPEC);
|
||||
|
||||
/**
|
||||
* Which selection paint the composer installs. The split is the platform's,
|
||||
* not a preference: iOS is the one place where restoring native selection
|
||||
* paint and caret costs measurable input latency, and the only place
|
||||
* CodeMirror supplies replacement drag handles.
|
||||
*
|
||||
* The caller can pass the policy, so the choice stays testable and is made
|
||||
* once per editor rather than once per module load.
|
||||
*/
|
||||
export function composerSelectionExtension(
|
||||
useCodeMirrorIOSHandles: boolean = usesCodeMirrorIOSSelectionHandles(),
|
||||
): Extension {
|
||||
return useCodeMirrorIOSHandles
|
||||
? composerIOSSelectionExtension
|
||||
: composerNativeSelectionExtension;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors @codemirror/view 6.43.9's iOS predicate. This branch may only rely
|
||||
* on the drawn handles when CodeMirror itself will create them; a broader iOS
|
||||
* heuristic could remove the native fallback without installing a replacement.
|
||||
*/
|
||||
export function isCodeMirrorIOSNavigator(
|
||||
userAgent: string,
|
||||
vendor: string,
|
||||
maxTouchPoints: number,
|
||||
): boolean {
|
||||
const isIE = /Edge\/(\d+)/.test(userAgent)
|
||||
|| /MSIE \d/.test(userAgent)
|
||||
|| /Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.test(userAgent);
|
||||
if (isIE || !/Apple Computer/.test(vendor)) return false;
|
||||
return /Mobile\/\w+/.test(userAgent) || maxTouchPoints > 2;
|
||||
}
|
||||
|
||||
function usesCodeMirrorIOSSelectionHandles(): boolean {
|
||||
const nav = globalThis.navigator;
|
||||
if (!nav) return false;
|
||||
return isCodeMirrorIOSNavigator(
|
||||
nav.userAgent || '',
|
||||
nav.vendor || '',
|
||||
nav.maxTouchPoints ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user