Skip to content

Commit 27dc68c

Browse files
authored
fix(VMaskInput): fix caret position while editing (#21925)
1 parent 5f8abb6 commit 27dc68c

File tree

2 files changed

+439
-35
lines changed

2 files changed

+439
-35
lines changed

packages/vuetify/src/labs/VMaskInput/VMaskInput.tsx

Lines changed: 141 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { isMaskDelimiter, makeMaskProps, useMask } from '@/composables/mask'
77
import { useProxiedModel } from '@/composables/proxiedModel'
88

99
// Utilities
10-
import { computed, onBeforeMount, ref, shallowRef, toRef } from 'vue'
10+
import { computed, nextTick, onBeforeMount, ref, shallowRef, toRef } from 'vue'
1111
import { genericComponent, propsFactory, useRender } from '@/util'
1212

1313
// Types
@@ -33,8 +33,8 @@ export const VMaskInput = genericComponent<VMaskInputSlots>()({
3333
setup (props, { slots, emit }) {
3434
const vTextFieldRef = ref<VTextField>()
3535

36-
const selection = shallowRef(0)
37-
const lazySelection = shallowRef(0)
36+
const inputAction = shallowRef()
37+
const caretPosition = shallowRef(0)
3838

3939
const mask = useMask(props)
4040
const returnMaskedValue = computed(() => props.mask && props.returnMaskedValue)
@@ -47,62 +47,166 @@ export const VMaskInput = genericComponent<VMaskInputSlots>()({
4747
val => props.mask ? mask.mask(mask.unmask(val)) : val,
4848
val => {
4949
if (props.mask) {
50-
const valueBeforeChange = mask.unmask(model.value)
50+
const valueWithoutDelimiters = removeMaskDelimiters(val)
51+
5152
// E.g. mask is #-# and the input value is '2-23'
5253
// model-value should be enforced to '2-2'
53-
const enforcedMaskedValue = mask.mask(mask.unmask(val))
54-
const newUnmaskedValue = mask.unmask(enforcedMaskedValue)
55-
56-
if (newUnmaskedValue === valueBeforeChange) {
57-
vTextFieldRef.value!.value = enforcedMaskedValue
58-
}
59-
val = newUnmaskedValue
60-
updateRange()
61-
return returnMaskedValue.value ? mask.mask(val) : val
54+
const newMaskedValue = mask.mask(valueWithoutDelimiters)
55+
const newUnmaskedValue = mask.unmask(newMaskedValue)
56+
57+
const newCaretPosition = getNewCaretPosition({
58+
oldValue: model.value,
59+
newValue: newMaskedValue,
60+
oldCaret: caretPosition.value,
61+
})
62+
63+
vTextFieldRef.value!.value = newMaskedValue
64+
vTextFieldRef.value!.setSelectionRange(newCaretPosition, newCaretPosition)
65+
66+
return returnMaskedValue.value ? mask.mask(newUnmaskedValue) : newUnmaskedValue
6267
}
6368
return val
6469
},
6570
)
6671

6772
const validationValue = toRef(() => returnMaskedValue.value ? model.value : mask.unmask(model.value))
6873

74+
function removeMaskDelimiters (val: string): string {
75+
return val.split('').filter(ch => !isMaskDelimiter(ch)).join('')
76+
}
77+
78+
function getNewCaretPosition ({
79+
oldValue,
80+
newValue,
81+
oldCaret,
82+
}: {
83+
oldValue: string
84+
newValue: string
85+
oldCaret: number
86+
}): number {
87+
if (!newValue) return 0
88+
if (!oldValue) return newValue.length
89+
90+
let newCaret: number
91+
92+
if (inputAction.value === 'Backspace') {
93+
newCaret = oldCaret - 1
94+
while (newCaret > 0 && isMaskDelimiter(newValue[newCaret - 1])) newCaret--
95+
} else if (inputAction.value === 'Delete') {
96+
newCaret = oldCaret
97+
} else { // insertion
98+
newCaret = oldCaret + 1
99+
while (isMaskDelimiter(newValue[newCaret])) newCaret++
100+
if (isMaskDelimiter(newValue[oldCaret])) newCaret++
101+
}
102+
103+
return newCaret
104+
}
105+
69106
onBeforeMount(() => {
70107
if (props.returnMaskedValue) {
71108
emit('update:modelValue', model.value)
72109
}
73110
})
74111

75-
function setCaretPosition (newSelection: number) {
76-
selection.value = newSelection
77-
vTextFieldRef.value && vTextFieldRef.value.setSelectionRange(selection.value, selection.value)
112+
function onKeyDown (e: KeyboardEvent) {
113+
if (e.metaKey) return
114+
115+
const inputElement = e.target as HTMLInputElement
116+
117+
caretPosition.value = inputElement.selectionStart || 0
118+
inputAction.value = e.key
119+
120+
const hasSelection = inputElement.selectionStart !== inputElement.selectionEnd
121+
if (e.key === 'Backspace' && hasSelection) {
122+
e.preventDefault()
123+
deleteSelection(e)
124+
}
125+
}
126+
127+
async function onCut (e: Event) {
128+
e.preventDefault()
129+
130+
copySelectionToClipboard(e)
131+
deleteSelection(e)
132+
}
133+
134+
async function onPaste (e: ClipboardEvent) {
135+
e.preventDefault()
136+
137+
const inputElement = e.target as HTMLInputElement
138+
const pastedString = removeMaskDelimiters(e.clipboardData?.getData('text') || '')
139+
140+
if (!pastedString) return
141+
142+
const pastedCharacters = [...pastedString]
143+
144+
const hasSelection = inputElement.selectionStart !== inputElement.selectionEnd
145+
146+
if (hasSelection) {
147+
replaceSelection(inputElement, pastedCharacters)
148+
} else {
149+
insertCharacters(inputElement, pastedCharacters)
150+
}
78151
}
79152

80-
function resetSelections () {
81-
if (!vTextFieldRef.value?.selectionEnd) return
153+
function copySelectionToClipboard (e: Event) {
154+
const inputElement = e.target as HTMLInputElement
155+
const start = inputElement.selectionStart || 0
156+
const end = inputElement.selectionEnd || 0
157+
const selectedText = inputElement.value.substring(start, end)
158+
navigator.clipboard.writeText(selectedText)
159+
}
82160

83-
selection.value = vTextFieldRef.value.selectionEnd
84-
lazySelection.value = 0
161+
async function deleteSelection (e: Event) {
162+
const inputElement = e.target as HTMLInputElement
163+
const curStart = inputElement.selectionStart || 0
164+
caretPosition.value = inputElement.selectionEnd || 0
85165

86-
for (let index = 0; index < selection.value; index++) {
87-
isMaskDelimiter(vTextFieldRef.value.value[index]) || lazySelection.value++
166+
while (caretPosition.value > curStart) {
167+
const success = await simulateBackspace(inputElement)
168+
if (!success) break
88169
}
89170
}
90171

91-
function updateRange () {
92-
if (!vTextFieldRef.value) return
93-
resetSelections()
172+
async function simulateBackspace (inputElement: HTMLInputElement) {
173+
inputAction.value = 'Backspace'
174+
model.value = inputElement.value.slice(0, caretPosition.value - 1) + inputElement.value.slice(caretPosition.value)
175+
inputAction.value = ''
176+
if (caretPosition.value === inputElement.selectionEnd) return false
177+
caretPosition.value = inputElement.selectionEnd || 0
178+
await nextTick()
179+
return true
180+
}
181+
182+
async function insertCharacters (inputElement: HTMLInputElement, pastedCharacters: string[]) {
183+
for (let i = 0; i < pastedCharacters.length; i++) {
184+
await insertCharacter(inputElement, pastedCharacters[i])
185+
}
186+
}
94187

95-
let selection = 0
96-
const newValue = vTextFieldRef.value.value
188+
async function insertCharacter (inputElement: HTMLInputElement, character: string) {
189+
caretPosition.value = inputElement.selectionEnd || 0
190+
model.value = inputElement.value.slice(0, caretPosition.value) + character + inputElement.value.slice(caretPosition.value)
191+
await nextTick()
192+
}
97193

98-
if (newValue) {
99-
for (let index = 0; index < newValue.length; index++) {
100-
if (lazySelection.value <= 0) break
101-
isMaskDelimiter(newValue[index]) || lazySelection.value--
102-
selection++
103-
}
194+
async function replaceSelection (inputElement: HTMLInputElement, pastedCharacters: string[]) {
195+
caretPosition.value = inputElement.selectionStart || 0
196+
for (let i = 0; i < pastedCharacters.length; i++) {
197+
await replaceCharacter(caretPosition.value, pastedCharacters[i])
198+
caretPosition.value++
104199
}
105-
setCaretPosition(selection)
200+
}
201+
202+
async function replaceCharacter (index: number, character: string) {
203+
let targetIndex = index
204+
205+
// Find next non-delimiter position
206+
while (targetIndex < model.value.length && isMaskDelimiter(model.value[targetIndex])) targetIndex++
207+
208+
model.value = model.value.slice(0, targetIndex) + character + model.value.slice(targetIndex + 1)
209+
await nextTick()
106210
}
107211

108212
useRender(() => {
@@ -114,6 +218,9 @@ export const VMaskInput = genericComponent<VMaskInputSlots>()({
114218
v-model={ model.value }
115219
ref={ vTextFieldRef }
116220
validationValue={ validationValue.value }
221+
onCut={ onCut }
222+
onPaste={ onPaste }
223+
onKeydown={ onKeyDown }
117224
>
118225
{{ ...slots }}
119226
</VTextField>

0 commit comments

Comments
 (0)