fix(ui): use CodeMirror selection handles on iOS
This commit is contained in:
@@ -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