Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions docs/demo/getFormattedSelection.md
Original file line number Diff line number Diff line change
@@ -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.
41 changes: 41 additions & 0 deletions src/getFormattedSelection.ts
Original file line number Diff line number Diff line change
@@ -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) };
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
66 changes: 66 additions & 0 deletions tests/getFormattedSelection.test.ts
Original file line number Diff line number Diff line change
@@ -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));
});
});