Skip to content

Commit

Permalink
feat: rewrite selection handling (#776)
Browse files Browse the repository at this point in the history
Rewrite the selection utility and refactor how user (character) input is applied.

* rewrite selection handling

* update selection when moving focus
  • Loading branch information
ph-fritsche committed Nov 28, 2021
1 parent 0badabd commit 968c2c4
Show file tree
Hide file tree
Showing 31 changed files with 833 additions and 454 deletions.
1 change: 1 addition & 0 deletions src/document/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,4 @@ function prepareElement(el: Node | HTMLInputElement) {

export {getUIValue, setUIValue, startTrackValue, endTrackValue} from './value'
export {getUISelection, setUISelection} from './selection'
export type {UISelectionRange} from './selection'
74 changes: 54 additions & 20 deletions src/document/selection.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {getUIValue} from '.'
import {prepareInterceptor} from './interceptor'

const UISelection = Symbol('Displayed selection in UI')
Expand All @@ -6,9 +7,19 @@ interface Value extends Number {
[UISelection]?: typeof UISelection
}

export interface UISelectionRange {
startOffset: number
endOffset: number
}

export interface UISelection {
anchorOffset: number
focusOffset: number
}

declare global {
interface Element {
[UISelection]?: {start: number; end: number}
[UISelection]?: UISelection
}
}

Expand All @@ -26,9 +37,9 @@ export function prepareSelectionInterceptor(
) {
const isUI = start && typeof start === 'object' && start[UISelection]

this[UISelection] = isUI
? {start: start.valueOf(), end: Number(end)}
: undefined
if (!isUI) {
this[UISelection] = undefined
}

return {
realArgs: [Number(start), end, direction] as [
Expand Down Expand Up @@ -62,21 +73,45 @@ export function prepareSelectionInterceptor(

export function setUISelection(
element: HTMLInputElement | HTMLTextAreaElement,
start: number,
end: number,
{
focusOffset: focusOffsetParam,
anchorOffset: anchorOffsetParam = focusOffsetParam,
}: {
anchorOffset?: number
focusOffset: number
},
mode: 'replace' | 'modify' = 'replace',
) {
element[UISelection] = {start, end}
const valueLength = getUIValue(element).length
const sanitizeOffset = (o: number) => Math.max(0, Math.min(valueLength, o))

const anchorOffset =
mode === 'replace' || element[UISelection] === undefined
? sanitizeOffset(anchorOffsetParam)
: (element[UISelection] as UISelection).anchorOffset
const focusOffset = sanitizeOffset(focusOffsetParam)

const startOffset = Math.min(anchorOffset, focusOffset)
const endOffset = Math.max(anchorOffset, focusOffset)

if (element.selectionStart === start && element.selectionEnd === end) {
element[UISelection] = {
anchorOffset,
focusOffset,
}

if (
element.selectionStart === startOffset &&
element.selectionEnd === endOffset
) {
return
}

// eslint-disable-next-line no-new-wrappers
const startObj = new Number(start)
const startObj = new Number(startOffset)
;(startObj as Value)[UISelection] = UISelection

try {
element.setSelectionRange(startObj as number, end)
element.setSelectionRange(startObj as number, endOffset)
} catch {
// DOMException for invalid state is expected when calling this
// on an element without support for setSelectionRange
Expand All @@ -86,16 +121,15 @@ export function setUISelection(
export function getUISelection(
element: HTMLInputElement | HTMLTextAreaElement,
) {
const ui = element[UISelection]
return ui === undefined
? {
selectionStart: element.selectionStart,
selectionEnd: element.selectionEnd,
}
: {
selectionStart: ui.start,
selectionEnd: ui.end,
}
const sel = element[UISelection] ?? {
anchorOffset: element.selectionStart ?? 0,
focusOffset: element.selectionEnd ?? 0,
}
return {
...sel,
startOffset: Math.min(sel.anchorOffset, sel.focusOffset),
endOffset: Math.max(sel.anchorOffset, sel.focusOffset),
}
}

export function clearUISelection(
Expand Down
4 changes: 3 additions & 1 deletion src/document/value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ export function setUIValue(
}

export function getUIValue(element: HTMLInputElement | HTMLTextAreaElement) {
return element[UIValue] === undefined ? element.value : element[UIValue]
return element[UIValue] === undefined
? element.value
: String(element[UIValue])
}

export function setInitialValue(
Expand Down
25 changes: 13 additions & 12 deletions src/keyboard/plugins/arrow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
*/

import {behaviorPlugin} from '../types'
import {getSelectionRange, isElementType, setSelectionRange} from '../../utils'
import {isElementType, setSelection} from '../../utils'
import {getUISelection} from '../../document'

export const keydownBehavior: behaviorPlugin[] = [
{
Expand All @@ -13,18 +14,18 @@ export const keydownBehavior: behaviorPlugin[] = [
(keyDef.key === 'ArrowLeft' || keyDef.key === 'ArrowRight') &&
isElementType(element, ['input', 'textarea']),
handle: (keyDef, element) => {
const {selectionStart, selectionEnd} = getSelectionRange(element)
const selection = getUISelection(element as HTMLInputElement)

const direction = keyDef.key === 'ArrowLeft' ? -1 : 1

const newPos =
(selectionStart === selectionEnd
? (selectionStart ?? /* istanbul ignore next */ 0) + direction
: direction < 0
? selectionStart
: selectionEnd) ?? /* istanbul ignore next */ 0

setSelectionRange(element, newPos, newPos)
// TODO: implement shift/ctrl
setSelection({
focusNode: element,
focusOffset:
selection.startOffset === selection.endOffset
? selection.focusOffset + (keyDef.key === 'ArrowLeft' ? -1 : 1)
: keyDef.key === 'ArrowLeft'
? selection.startOffset
: selection.endOffset,
})
},
},
]
76 changes: 29 additions & 47 deletions src/keyboard/plugins/character.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,17 @@ import {
buildTimeValue,
calculateNewValue,
fireInputEvent,
getInputRange,
getSpaceUntilMaxLength,
getValue,
isClickableInput,
isContentEditable,
isEditableInput,
isElementType,
isValidDateValue,
isValidInputTimeValue,
prepareInput,
} from '../../utils'
import {UISelectionRange} from '../../document'

export const keypressBehavior: behaviorPlugin[] = [
{
Expand All @@ -37,9 +40,10 @@ export const keypressBehavior: behaviorPlugin[] = [
newEntry = timeNewEntry
}

const {newValue, newSelectionStart} = calculateNewValue(
const {newValue, newOffset} = calculateNewValue(
newEntry,
element as HTMLElement,
element as HTMLInputElement & {type: 'time'},
getInputRange(element) as UISelectionRange,
)
const prevValue = getValue(element)

Expand All @@ -48,7 +52,10 @@ export const keypressBehavior: behaviorPlugin[] = [
if (prevValue !== newValue) {
fireInputEvent(element as HTMLInputElement, {
newValue,
newSelectionStart,
newSelection: {
node: element,
offset: newOffset,
},
eventOverrides: {
data: keyDef.key,
inputType: 'insertText',
Expand Down Expand Up @@ -81,9 +88,10 @@ export const keypressBehavior: behaviorPlugin[] = [
newEntry = textToBeTyped
}

const {newValue, newSelectionStart} = calculateNewValue(
const {newValue, newOffset} = calculateNewValue(
newEntry,
element as HTMLElement,
element as HTMLInputElement & {type: 'date'},
getInputRange(element) as UISelectionRange,
)
const prevValue = getValue(element)

Expand All @@ -92,7 +100,10 @@ export const keypressBehavior: behaviorPlugin[] = [
if (prevValue !== newValue) {
fireInputEvent(element as HTMLInputElement, {
newValue,
newSelectionStart,
newSelection: {
node: element,
offset: newOffset,
},
eventOverrides: {
data: keyDef.key,
inputType: 'insertText',
Expand All @@ -118,10 +129,10 @@ export const keypressBehavior: behaviorPlugin[] = [
return
}

const {newValue, newSelectionStart} = calculateNewValue(
const {newValue, commit} = prepareInput(
keyDef.key as string,
element as HTMLElement,
)
element,
) as NonNullable<ReturnType<typeof prepareInput>>

// the browser allows some invalid input but not others
// it allows up to two '-' at any place before any 'e' or one directly following 'e'
Expand All @@ -135,37 +146,18 @@ export const keypressBehavior: behaviorPlugin[] = [
return
}

fireInputEvent(element as HTMLInputElement, {
newValue,
newSelectionStart,
eventOverrides: {
data: keyDef.key,
inputType: 'insertText',
},
})
commit()
},
},
{
matches: (keyDef, element) =>
keyDef.key?.length === 1 &&
((isElementType(element, ['input', 'textarea'], {readOnly: false}) &&
!isClickableInput(element)) ||
(isEditableInput(element) ||
isElementType(element, 'textarea', {readOnly: false}) ||
isContentEditable(element)) &&
getSpaceUntilMaxLength(element) !== 0,
handle: (keyDef, element) => {
const {newValue, newSelectionStart} = calculateNewValue(
keyDef.key as string,
element as HTMLElement,
)

fireInputEvent(element as HTMLElement, {
newValue,
newSelectionStart,
eventOverrides: {
data: keyDef.key,
inputType: 'insertText',
},
})
prepareInput(keyDef.key as string, element)?.commit()
},
},
{
Expand All @@ -175,23 +167,13 @@ export const keypressBehavior: behaviorPlugin[] = [
isContentEditable(element)) &&
getSpaceUntilMaxLength(element) !== 0,
handle: (keyDef, element, options, state) => {
const {newValue, newSelectionStart} = calculateNewValue(
prepareInput(
'\n',
element as HTMLElement,
)

const inputType =
element,
isContentEditable(element) && !state.modifiers.shift
? 'insertParagraph'
: 'insertLineBreak'

fireInputEvent(element as HTMLElement, {
newValue,
newSelectionStart,
eventOverrides: {
inputType,
},
})
: 'insertLineBreak',
)?.commit()
},
},
]
22 changes: 3 additions & 19 deletions src/keyboard/plugins/control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,11 @@

import {behaviorPlugin} from '../types'
import {
calculateNewValue,
fireInputEvent,
getValue,
isContentEditable,
isCursorAtEnd,
isEditable,
isElementType,
prepareInput,
setSelectionRange,
} from '../../utils'

Expand Down Expand Up @@ -47,23 +45,9 @@ export const keydownBehavior: behaviorPlugin[] = [
},
{
matches: (keyDef, element) =>
keyDef.key === 'Delete' && isEditable(element) && !isCursorAtEnd(element),
keyDef.key === 'Delete' && isEditable(element),
handle: (keDef, element) => {
const {newValue, newSelectionStart} = calculateNewValue(
'',
element as HTMLElement,
undefined,
undefined,
'forward',
)

fireInputEvent(element as HTMLElement, {
newValue,
newSelectionStart,
eventOverrides: {
inputType: 'deleteContentForward',
},
})
prepareInput('', element, 'deleteContentForward')?.commit()
},
},
]
Loading

0 comments on commit 968c2c4

Please sign in to comment.