diff --git a/docs/demo/getFormattedSelection.md b/docs/demo/getFormattedSelection.md new file mode 100644 index 00000000..1d264883 --- /dev/null +++ b/docs/demo/getFormattedSelection.md @@ -0,0 +1,40 @@ +# getFormattedSelection + +Adjust an input selection after its text is formatted, for example when a formatter adds a prefix or separators, or changes letter case. + +```tsx | pure +import { getFormattedSelection } from '@rc-component/util'; + +// When the input changes, before formatting +const originStr = input.value; +const selection = { start: input.selectionStart, end: input.selectionEnd }; +const targetStr = format(originStr); + +// After rendering `targetStr` +const { start, end } = getFormattedSelection(originStr, selection, targetStr); +input.setSelectionRange(start, end); +``` + +```ts +interface SelectionRange { + start: number; + end: number; +} + +function getFormattedSelection( + originStr: string, + selection: SelectionRange, + targetStr: string, +): SelectionRange; +``` + +Each edge of the selection is placed by the first rule that applies: + +| Rule | `originStr` | Caret | `targetStr` | Result | +| --------------------------------------------------------------------------- | ----------- | ----- | ----------- | ------ | +| Keep it after the same text before it | `5` | 1 | `5%` | 1 | +| Keep it before the same text after it | `AbC` | 2 | `ABC` | 2 | +| Place it after the character before it, searched from its previous position | `9$ ` | 1 | `$ 9` | 3 | +| Move it to the end when that character is gone | `abc` | 1 | `xyz` | 3 | + +Offsets outside `originStr` are clamped, and the returned end is never before the start. The selection is not modified. diff --git a/src/getFormattedSelection.ts b/src/getFormattedSelection.ts new file mode 100644 index 00000000..ba8d3226 --- /dev/null +++ b/src/getFormattedSelection.ts @@ -0,0 +1,41 @@ +export interface SelectionRange { + start: number; + end: number; +} + +function getFormattedOffset( + originStr: string, + offset: number, + targetStr: string, +) { + const position = Math.min(Math.max(offset, 0), originStr.length); + const beforeStr = originStr.slice(0, position); + const afterStr = originStr.slice(position); + + if (targetStr.startsWith(beforeStr)) { + return position; + } + if (targetStr.endsWith(afterStr)) { + return targetStr.length - afterStr.length; + } + + // Both sides changed, e.g. `9$ ` formatted as `$ 9`: follow the character + // before the caret, searching from its previous position. + const index = targetStr.indexOf(originStr[position - 1], position - 1); + return index === -1 ? targetStr.length : index + 1; +} + +/** + * Adjust a selection of `originStr` after it is formatted as `targetStr`. + * Each edge stays after the same text before it, or before the same text after + * it. Otherwise it follows the character before it, or moves to the end. + */ +export default function getFormattedSelection( + originStr: string, + selection: SelectionRange, + targetStr: string, +): SelectionRange { + const start = getFormattedOffset(originStr, selection.start, targetStr); + const end = getFormattedOffset(originStr, selection.end, targetStr); + return { start, end: Math.max(start, end) }; +} diff --git a/src/index.ts b/src/index.ts index fd0a01f3..a840cebe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,6 +40,8 @@ export { getShadowRoot } from './Dom/shadow'; export { isStyleSupport } from './Dom/styleChecker'; export { default as KeyCode } from './KeyCode'; +export { default as getFormattedSelection } from './getFormattedSelection'; +export type { SelectionRange } from './getFormattedSelection'; export { default as getScrollBarSize, getTargetScrollBarSize, diff --git a/tests/getFormattedSelection.test.ts b/tests/getFormattedSelection.test.ts new file mode 100644 index 00000000..5d3a4ac8 --- /dev/null +++ b/tests/getFormattedSelection.test.ts @@ -0,0 +1,66 @@ +import { getFormattedSelection } from '../src'; +import type { SelectionRange } from '../src'; + +const caret = (position: number): SelectionRange => ({ + start: position, + end: position, +}); + +describe('getFormattedSelection', () => { + it.each<[string, string, number, string, number]>([ + ['unchanged text', '12', 1, '12', 1], + ['an appended suffix', '5', 1, '5%', 1], + ['an uppercase character typed in the middle', 'AbC', 2, 'ABC', 2], + ['an uppercase character typed at the end', 'Ab', 2, 'AB', 2], + ['a prepended prefix', '1', 1, '$ 1', 3], + ['a prefix and thousands separators', '123456', 3, '$ 123,456', 6], + ['a removed separator', '12,34', 3, '1,234', 3], + ['a caret at the start', '1', 0, '$ 1', 0], + ['an empty origin', '', 0, '$ ', 0], + ])('keeps the caret with %s', (_, originStr, position, targetStr, result) => { + expect( + getFormattedSelection(originStr, caret(position), targetStr), + ).toEqual(caret(result)); + }); + + it('follows the character before the caret when both sides change', () => { + // `$ ` typed `9` before the prefix, then formatted as `$ 9`. + expect(getFormattedSelection('9$ ', caret(1), '$ 9')).toEqual(caret(3)); + }); + + it('searches for the character before the caret from its previous position', () => { + // `$` is at index 0, before the search starts at index 1, so it is not found. + expect(getFormattedSelection('9$ ', caret(2), '$ 9')).toEqual(caret(3)); + // `b` is still at index 1, where the search starts, so it is found. + expect(getFormattedSelection('abX', caret(2), 'ZbY')).toEqual(caret(2)); + }); + + it('moves the caret to the end when the character before it is gone', () => { + expect(getFormattedSelection('abc', caret(1), 'xyz')).toEqual(caret(3)); + }); + + it('adjusts each edge of a selection', () => { + expect( + getFormattedSelection('1234', { start: 1, end: 3 }, '1,234'), + ).toEqual({ start: 1, end: 4 }); + }); + + it('does not return a selection that ends before it starts', () => { + expect(getFormattedSelection('a-b', { start: 1, end: 2 }, 'b-a')).toEqual( + caret(3), + ); + }); + + it('clamps offsets outside the original text', () => { + expect(getFormattedSelection('ab', { start: -1, end: 10 }, 'AB')).toEqual({ + start: 0, + end: 2, + }); + }); + + it('does not modify the selection', () => { + const selection = Object.freeze(caret(1)); + getFormattedSelection('9$ ', selection, '$ 9'); + expect(selection).toEqual(caret(1)); + }); +});