fix: preserve composer double-space periods
This commit is contained in:
@@ -96,6 +96,7 @@ import {
|
||||
type ComposerEditorHandle,
|
||||
} from './composer/editor/ComposerEditor';
|
||||
import { createComposerEditorViewStore } from './composer/editor/viewStore';
|
||||
import { composerAutoCorrect } from './composer/editor/autocorrect';
|
||||
import {
|
||||
appendInlineText,
|
||||
appendWithLineBreaks,
|
||||
@@ -2624,7 +2625,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
: t(useCompactChatPlaceholder ? 'chat.chatInput.placeholder.chatCompact' : 'chat.chatInput.placeholder.chat')
|
||||
: t('chat.chatInput.placeholder.selectSession')}
|
||||
editable={Boolean(currentSessionId || newSessionDraftOpen)}
|
||||
autoCorrect={isMobile}
|
||||
autoCorrect={composerAutoCorrect({ isMobile })}
|
||||
autoCapitalize={isMobile ? 'sentences' : 'none'}
|
||||
spellCheck={isMobile || inputSpellcheckEnabled}
|
||||
fillContainer={isComposerExpanded}
|
||||
|
||||
@@ -76,6 +76,14 @@ token:
|
||||
themes define `--interactive-selection` with its own alpha, so a translucent
|
||||
mix of it is nearly invisible.
|
||||
|
||||
The content element keeps the existing correction policy: on in the mobile UI,
|
||||
off elsewhere. CodeMirror also reads the attribute and reverts Apple and
|
||||
Android's insert-period-on-double-space only when its value is exactly `off`.
|
||||
`editor/autocorrect.ts` uses the HTML standard's
|
||||
[ASCII case-insensitive `autocorrect` keywords](https://html.spec.whatwg.org/multipage/interaction.html#attr-autocorrect)
|
||||
to keep desktop word correction off while avoiding that CodeMirror-only
|
||||
revert. Its platform checks deliberately match CodeMirror's own browser flags.
|
||||
|
||||
`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
|
||||
is cheaper and far simpler than incremental mapping, and it keeps the editor
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ComposerLanguageContext } from '../language/tokenize';
|
||||
import type { ComposerAutoCorrect } from './autocorrect';
|
||||
import { composerLanguage, setLanguageContext } from './composerLanguage';
|
||||
import type { ComposerEditorViewStore } from './viewStore';
|
||||
import { composerEditorTheme, composerNativeSelectionExtension } from './theme';
|
||||
@@ -89,8 +90,11 @@ export interface ComposerEditorProps {
|
||||
placeholder?: string;
|
||||
editable?: boolean;
|
||||
spellCheck?: boolean;
|
||||
/** Mobile keyboards; ignored on desktop. */
|
||||
autoCorrect?: boolean;
|
||||
/**
|
||||
* The content element's autocorrect keyword. See `autocorrect.ts` for the
|
||||
* case-sensitive CodeMirror workaround.
|
||||
*/
|
||||
autoCorrect?: ComposerAutoCorrect;
|
||||
autoCapitalize?: 'none' | 'sentences';
|
||||
/** Fill the available height instead of growing with the content. */
|
||||
fillContainer?: boolean;
|
||||
@@ -147,7 +151,7 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
|
||||
placeholder,
|
||||
editable = true,
|
||||
spellCheck = false,
|
||||
autoCorrect = false,
|
||||
autoCorrect = 'off',
|
||||
autoCapitalize = 'none',
|
||||
fillContainer = false,
|
||||
maxLines = 8,
|
||||
@@ -262,7 +266,7 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
|
||||
}),
|
||||
EditorView.contentAttributes.of({
|
||||
spellcheck: String(handlersRef.current.spellCheck ?? false),
|
||||
autocorrect: handlersRef.current.autoCorrect ? 'on' : 'off',
|
||||
autocorrect: handlersRef.current.autoCorrect ?? 'off',
|
||||
autocapitalize: handlersRef.current.autoCapitalize ?? 'none',
|
||||
...(handlersRef.current['aria-label']
|
||||
? { 'aria-label': handlersRef.current['aria-label'] }
|
||||
@@ -406,7 +410,7 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
|
||||
if (!view) return;
|
||||
const content = view.contentDOM;
|
||||
content.setAttribute('spellcheck', String(spellCheck));
|
||||
content.setAttribute('autocorrect', autoCorrect ? 'on' : 'off');
|
||||
content.setAttribute('autocorrect', autoCorrect);
|
||||
content.setAttribute('autocapitalize', autoCapitalize);
|
||||
}, [autoCapitalize, autoCorrect, spellCheck]);
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { composerAutoCorrect, type ComposerAutoCorrect } from '../autocorrect';
|
||||
|
||||
const platform = (overrides: Partial<Navigator>): Navigator => ({
|
||||
maxTouchPoints: 0,
|
||||
platform: '',
|
||||
userAgent: '',
|
||||
vendor: '',
|
||||
...overrides,
|
||||
} as Navigator);
|
||||
|
||||
const codeMirrorKeepsDoubleSpacePeriod = (
|
||||
autoCorrect: ComposerAutoCorrect,
|
||||
): boolean => autoCorrect !== 'off';
|
||||
|
||||
const affectedPlatforms: Array<[string, Navigator]> = [
|
||||
['macOS', platform({ platform: 'MacIntel' })],
|
||||
['iPhone', platform({
|
||||
platform: 'iPhone',
|
||||
userAgent: 'Mozilla/5.0 Mobile/15E148 Safari/604.1',
|
||||
vendor: 'Apple Computer, Inc.',
|
||||
})],
|
||||
['iPadOS touch detection', platform({
|
||||
maxTouchPoints: 5,
|
||||
userAgent: 'Mozilla/5.0 Version/17.4 Safari/605.1.15',
|
||||
vendor: 'Apple Computer, Inc.',
|
||||
})],
|
||||
['Android', platform({
|
||||
platform: 'Linux armv8l',
|
||||
userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 8)',
|
||||
})],
|
||||
];
|
||||
|
||||
const unaffectedPlatforms: Array<[string, Navigator]> = [
|
||||
['Windows', platform({ platform: 'Win32' })],
|
||||
['Linux', platform({ platform: 'Linux x86_64' })],
|
||||
];
|
||||
|
||||
describe('composerAutoCorrect', () => {
|
||||
test('matches the pinned CodeMirror period-revert guard', () => {
|
||||
const source = readFileSync(
|
||||
fileURLToPath(import.meta.resolve('@codemirror/view')),
|
||||
'utf8',
|
||||
);
|
||||
const semantics = source
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/\s+/g, '');
|
||||
|
||||
expect(/getAttribute\(["']autocorrect["']\)==["']off["']/.test(semantics)).toBe(true);
|
||||
expect(semantics).toContain(
|
||||
'constios=safari&&(/Mobile\\/\\w+/.test(nav.userAgent)||nav.maxTouchPoints>2)',
|
||||
);
|
||||
expect(semantics).toContain('mac:ios||/Mac/.test(nav.platform)');
|
||||
expect(semantics).toContain('android:/Android\\b/.test(nav.userAgent)');
|
||||
});
|
||||
|
||||
for (const [name, navigator] of affectedPlatforms) {
|
||||
test(`preserves the ${name} platform period without enabling autocorrect`, () => {
|
||||
const autoCorrect = composerAutoCorrect({ isMobile: false, navigator });
|
||||
|
||||
expect(autoCorrect.toLowerCase()).toBe('off');
|
||||
// @codemirror/view 6.39.13 reverts the native period only for exact "off".
|
||||
expect(codeMirrorKeepsDoubleSpacePeriod(autoCorrect)).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const [name, navigator] of unaffectedPlatforms) {
|
||||
test(`leaves desktop correction off on ${name}`, () => {
|
||||
expect(composerAutoCorrect({ isMobile: false, navigator })).toBe('off');
|
||||
});
|
||||
}
|
||||
|
||||
test('uses CodeMirror platform detection rather than a macOS user agent', () => {
|
||||
expect(composerAutoCorrect({
|
||||
isMobile: false,
|
||||
navigator: platform({
|
||||
platform: 'Linux x86_64',
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
|
||||
}),
|
||||
})).toBe('off');
|
||||
});
|
||||
|
||||
test('preserves the existing mobile autocorrect policy', () => {
|
||||
expect(composerAutoCorrect({
|
||||
isMobile: true,
|
||||
navigator: platform({ platform: 'Win32' }),
|
||||
})).toBe('on');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
export type ComposerAutoCorrect = 'on' | 'off' | 'Off';
|
||||
|
||||
type PlatformNavigator = Pick<Navigator,
|
||||
'maxTouchPoints' | 'platform' | 'userAgent' | 'vendor'
|
||||
>;
|
||||
|
||||
/** Keep desktop autocorrect off without triggering CodeMirror's period revert. */
|
||||
export function composerAutoCorrect(options: {
|
||||
isMobile: boolean;
|
||||
navigator?: PlatformNavigator;
|
||||
}): ComposerAutoCorrect {
|
||||
if (options.isMobile) return 'on';
|
||||
|
||||
const nav = options.navigator
|
||||
?? (typeof navigator === 'undefined'
|
||||
? { maxTouchPoints: 0, platform: '', userAgent: '', vendor: '' }
|
||||
: navigator);
|
||||
// These must match CodeMirror's flags because its revert checks exact "off".
|
||||
const ios = /Apple Computer/.test(nav.vendor)
|
||||
&& (/Mobile\/\w+/.test(nav.userAgent) || nav.maxTouchPoints > 2);
|
||||
return ios || /Mac/.test(nav.platform) || /Android\b/.test(nav.userAgent)
|
||||
? 'Off'
|
||||
: 'off';
|
||||
}
|
||||
Reference in New Issue
Block a user