Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: rewrite selection handling #776

Merged
merged 9 commits into from
Nov 9, 2021
Merged
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
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