fix: preserve composer double-space periods
This commit is contained in:
@@ -96,6 +96,7 @@ import {
|
|||||||
type ComposerEditorHandle,
|
type ComposerEditorHandle,
|
||||||
} from './composer/editor/ComposerEditor';
|
} from './composer/editor/ComposerEditor';
|
||||||
import { createComposerEditorViewStore } from './composer/editor/viewStore';
|
import { createComposerEditorViewStore } from './composer/editor/viewStore';
|
||||||
|
import { composerAutoCorrect } from './composer/editor/autocorrect';
|
||||||
import {
|
import {
|
||||||
appendInlineText,
|
appendInlineText,
|
||||||
appendWithLineBreaks,
|
appendWithLineBreaks,
|
||||||
@@ -2624,7 +2625,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
: t(useCompactChatPlaceholder ? 'chat.chatInput.placeholder.chatCompact' : 'chat.chatInput.placeholder.chat')
|
: t(useCompactChatPlaceholder ? 'chat.chatInput.placeholder.chatCompact' : 'chat.chatInput.placeholder.chat')
|
||||||
: t('chat.chatInput.placeholder.selectSession')}
|
: t('chat.chatInput.placeholder.selectSession')}
|
||||||
editable={Boolean(currentSessionId || newSessionDraftOpen)}
|
editable={Boolean(currentSessionId || newSessionDraftOpen)}
|
||||||
autoCorrect={isMobile}
|
autoCorrect={composerAutoCorrect({ isMobile })}
|
||||||
autoCapitalize={isMobile ? 'sentences' : 'none'}
|
autoCapitalize={isMobile ? 'sentences' : 'none'}
|
||||||
spellCheck={isMobile || inputSpellcheckEnabled}
|
spellCheck={isMobile || inputSpellcheckEnabled}
|
||||||
fillContainer={isComposerExpanded}
|
fillContainer={isComposerExpanded}
|
||||||
|
|||||||
@@ -76,6 +76,14 @@ token:
|
|||||||
themes define `--interactive-selection` with its own alpha, so a translucent
|
themes define `--interactive-selection` with its own alpha, so a translucent
|
||||||
mix of it is nearly invisible.
|
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
|
`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
|
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
|
is cheaper and far simpler than incremental mapping, and it keeps the editor
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { ComposerLanguageContext } from '../language/tokenize';
|
import type { ComposerLanguageContext } from '../language/tokenize';
|
||||||
|
import type { ComposerAutoCorrect } from './autocorrect';
|
||||||
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';
|
||||||
@@ -89,8 +90,11 @@ export interface ComposerEditorProps {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
editable?: boolean;
|
editable?: boolean;
|
||||||
spellCheck?: 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';
|
autoCapitalize?: 'none' | 'sentences';
|
||||||
/** Fill the available height instead of growing with the content. */
|
/** Fill the available height instead of growing with the content. */
|
||||||
fillContainer?: boolean;
|
fillContainer?: boolean;
|
||||||
@@ -147,7 +151,7 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
|
|||||||
placeholder,
|
placeholder,
|
||||||
editable = true,
|
editable = true,
|
||||||
spellCheck = false,
|
spellCheck = false,
|
||||||
autoCorrect = false,
|
autoCorrect = 'off',
|
||||||
autoCapitalize = 'none',
|
autoCapitalize = 'none',
|
||||||
fillContainer = false,
|
fillContainer = false,
|
||||||
maxLines = 8,
|
maxLines = 8,
|
||||||
@@ -262,7 +266,7 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
|
|||||||
}),
|
}),
|
||||||
EditorView.contentAttributes.of({
|
EditorView.contentAttributes.of({
|
||||||
spellcheck: String(handlersRef.current.spellCheck ?? false),
|
spellcheck: String(handlersRef.current.spellCheck ?? false),
|
||||||
autocorrect: handlersRef.current.autoCorrect ? 'on' : 'off',
|
autocorrect: handlersRef.current.autoCorrect ?? 'off',
|
||||||
autocapitalize: handlersRef.current.autoCapitalize ?? 'none',
|
autocapitalize: handlersRef.current.autoCapitalize ?? 'none',
|
||||||
...(handlersRef.current['aria-label']
|
...(handlersRef.current['aria-label']
|
||||||
? { 'aria-label': handlersRef.current['aria-label'] }
|
? { 'aria-label': handlersRef.current['aria-label'] }
|
||||||
@@ -406,7 +410,7 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
|
|||||||
if (!view) return;
|
if (!view) return;
|
||||||
const content = view.contentDOM;
|
const content = view.contentDOM;
|
||||||
content.setAttribute('spellcheck', String(spellCheck));
|
content.setAttribute('spellcheck', String(spellCheck));
|
||||||
content.setAttribute('autocorrect', autoCorrect ? 'on' : 'off');
|
content.setAttribute('autocorrect', autoCorrect);
|
||||||
content.setAttribute('autocapitalize', autoCapitalize);
|
content.setAttribute('autocapitalize', autoCapitalize);
|
||||||
}, [autoCapitalize, autoCorrect, spellCheck]);
|
}, [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