mirror of
https://github.com/tenrok/maska.git
synced 2026-06-08 17:22:27 +03:00
feat!: separate packages
This commit is contained in:
@@ -1,7 +0,0 @@
|
||||
import { Mask, MaskType, MaskOptions } from './mask'
|
||||
import { MaskInput, MaskInputOptions, MaskaDetail } from './input'
|
||||
import { vMaska } from './vue'
|
||||
import { tokens, MaskTokens } from './tokens'
|
||||
|
||||
export { Mask, MaskInput, tokens, vMaska }
|
||||
export type { MaskaDetail, MaskInputOptions, MaskOptions, MaskTokens, MaskType }
|
||||
-155
@@ -1,155 +0,0 @@
|
||||
import { Mask, MaskOptions } from './mask'
|
||||
import { parseInput } from './parser'
|
||||
|
||||
type OnMaskaType = (detail: MaskaDetail) => void
|
||||
type MaskaTarget = string | NodeListOf<HTMLInputElement> | HTMLInputElement
|
||||
|
||||
export interface MaskInputOptions extends MaskOptions {
|
||||
onMaska?: OnMaskaType | OnMaskaType[]
|
||||
preProcess?: (value: string) => string
|
||||
postProcess?: (value: string) => string
|
||||
}
|
||||
|
||||
export interface MaskaDetail {
|
||||
masked: string
|
||||
unmasked: string
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
export class MaskInput {
|
||||
readonly items = new Map<HTMLInputElement, Mask>()
|
||||
|
||||
constructor (target: MaskaTarget, private options: MaskInputOptions = {}) {
|
||||
this.init(this.getInputs(target))
|
||||
}
|
||||
|
||||
update (options: MaskInputOptions = {}): void {
|
||||
const needUpdate = JSON.stringify(options) !== JSON.stringify(this.options)
|
||||
this.options = options
|
||||
|
||||
this.init(Array.from(this.items.keys()), needUpdate)
|
||||
}
|
||||
|
||||
updateValue (input: HTMLInputElement): void {
|
||||
if (input.value !== '' && input.value !== this.processInput(input).masked) {
|
||||
this.setValue(input, input.value)
|
||||
}
|
||||
}
|
||||
|
||||
destroy (): void {
|
||||
for (const input of this.items.keys()) {
|
||||
input.removeEventListener('input', this.onInput)
|
||||
}
|
||||
this.items.clear()
|
||||
}
|
||||
|
||||
private init (inputs: HTMLInputElement[], update = true): void {
|
||||
const defaults = this.getOptions(this.options)
|
||||
|
||||
for (const input of inputs) {
|
||||
if (!this.items.has(input)) {
|
||||
input.addEventListener('input', this.onInput, { capture: true })
|
||||
}
|
||||
|
||||
const mask = new Mask(parseInput(input, defaults))
|
||||
this.items.set(input, mask)
|
||||
|
||||
if (update) {
|
||||
this.updateValue(input)
|
||||
|
||||
if (input.selectionStart === null && mask.isEager()) {
|
||||
console.warn('Maska: input of `%s` type is not supported', input.type)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getInputs (target: MaskaTarget): HTMLInputElement[] {
|
||||
if (typeof target === 'string') {
|
||||
return Array.from(document.querySelectorAll(target))
|
||||
}
|
||||
|
||||
return 'length' in target ? Array.from(target) : [target]
|
||||
}
|
||||
|
||||
private getOptions (options: MaskInputOptions): MaskOptions {
|
||||
const { onMaska, preProcess, postProcess, ...opts } = options
|
||||
|
||||
return opts
|
||||
}
|
||||
|
||||
private readonly onInput = (e: Event | InputEvent): void => {
|
||||
if (e instanceof CustomEvent && e.type === 'input') {
|
||||
return
|
||||
}
|
||||
|
||||
const input = e.target as HTMLInputElement
|
||||
const mask = this.items.get(input) as Mask
|
||||
const isDelete = 'inputType' in e && e.inputType.startsWith('delete')
|
||||
const isEager = mask.isEager()
|
||||
|
||||
const value = (isDelete && isEager && mask.unmasked(input.value) === '')
|
||||
? ''
|
||||
: input.value
|
||||
|
||||
this.fixCursor(input, isDelete, () => this.setValue(input, value))
|
||||
}
|
||||
|
||||
private fixCursor (input: HTMLInputElement, isDelete: boolean, closure: CallableFunction): void {
|
||||
const pos = input.selectionStart
|
||||
const value = input.value
|
||||
|
||||
closure()
|
||||
|
||||
// if pos is null, it means element does not support setSelectionRange
|
||||
// and when cursor at the end, process only on delete event
|
||||
if (pos === null || (pos === value.length && !isDelete)) return
|
||||
|
||||
const valueNew = input.value
|
||||
const leftPart = value.slice(0, pos)
|
||||
const leftPartNew = valueNew.slice(0, pos)
|
||||
const unmasked = this.processInput(input, leftPart).unmasked
|
||||
const unmaskedNew = this.processInput(input, leftPartNew).unmasked
|
||||
const newPos = pos + (unmasked.length - unmaskedNew.length)
|
||||
|
||||
input.setSelectionRange(newPos, newPos)
|
||||
}
|
||||
|
||||
private setValue (input: HTMLInputElement, value: string): void {
|
||||
const detail = this.processInput(input, value)
|
||||
|
||||
input.value = detail.masked
|
||||
|
||||
if (this.options.onMaska != null) {
|
||||
if (Array.isArray(this.options.onMaska)) {
|
||||
this.options.onMaska.forEach((f) => f(detail))
|
||||
} else {
|
||||
this.options.onMaska(detail)
|
||||
}
|
||||
}
|
||||
|
||||
input.dispatchEvent(new CustomEvent<MaskaDetail>('maska', { detail }))
|
||||
input.dispatchEvent(new CustomEvent('input', { detail: detail.masked }))
|
||||
}
|
||||
|
||||
private processInput (input: HTMLInputElement, value?: string): MaskaDetail {
|
||||
const mask = this.items.get(input) as Mask
|
||||
let valueNew = value ?? input.value
|
||||
|
||||
if (this.options.preProcess != null) {
|
||||
valueNew = this.options.preProcess(valueNew)
|
||||
}
|
||||
|
||||
let masked = mask.masked(valueNew)
|
||||
|
||||
if (this.options.postProcess != null) {
|
||||
masked = this.options.postProcess(masked)
|
||||
}
|
||||
|
||||
return {
|
||||
masked,
|
||||
unmasked: mask.unmasked(valueNew),
|
||||
completed: mask.completed(valueNew)
|
||||
}
|
||||
}
|
||||
}
|
||||
-229
@@ -1,229 +0,0 @@
|
||||
import { MaskTokens, tokens } from './tokens'
|
||||
|
||||
export type MaskType = string | string[] | ((input: string) => string) | null
|
||||
|
||||
export interface MaskOptions {
|
||||
mask?: MaskType
|
||||
tokens?: MaskTokens
|
||||
tokensReplace?: boolean
|
||||
eager?: boolean
|
||||
reversed?: boolean
|
||||
}
|
||||
|
||||
export class Mask {
|
||||
readonly opts: MaskOptions = {}
|
||||
private readonly memo = new Map()
|
||||
|
||||
constructor (defaults: MaskOptions = {}) {
|
||||
const opts = { ...defaults }
|
||||
|
||||
if (opts.tokens != null) {
|
||||
opts.tokens = (opts.tokensReplace as boolean)
|
||||
? { ...opts.tokens }
|
||||
: { ...tokens, ...opts.tokens }
|
||||
|
||||
for (const token of Object.values(opts.tokens)) {
|
||||
if (typeof token.pattern === 'string') {
|
||||
token.pattern = new RegExp(token.pattern)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
opts.tokens = tokens
|
||||
}
|
||||
|
||||
if (Array.isArray(opts.mask)) {
|
||||
if (opts.mask.length > 1) {
|
||||
opts.mask = [...opts.mask].sort((a, b) => a.length - b.length)
|
||||
} else {
|
||||
opts.mask = opts.mask[0] ?? ''
|
||||
}
|
||||
}
|
||||
if (opts.mask === '') {
|
||||
opts.mask = null
|
||||
}
|
||||
|
||||
this.opts = opts
|
||||
}
|
||||
|
||||
masked (value: string): string {
|
||||
return this.process(value, this.findMask(value))
|
||||
}
|
||||
|
||||
unmasked (value: string): string {
|
||||
return this.process(value, this.findMask(value), false)
|
||||
}
|
||||
|
||||
isEager (): boolean {
|
||||
return this.opts.eager === true
|
||||
}
|
||||
|
||||
isReversed (): boolean {
|
||||
return this.opts.reversed === true
|
||||
}
|
||||
|
||||
completed (value: string): boolean {
|
||||
const mask = this.findMask(value)
|
||||
|
||||
if (this.opts.mask == null || mask == null) return false
|
||||
|
||||
const length = this.process(value, mask).length
|
||||
|
||||
if (typeof this.opts.mask === 'string') {
|
||||
return length >= this.opts.mask.length
|
||||
} else if (typeof this.opts.mask === 'function') {
|
||||
return length >= mask.length
|
||||
}
|
||||
|
||||
return this.opts.mask.filter((m) => length >= m.length).length === this.opts.mask.length
|
||||
}
|
||||
|
||||
private findMask (value: string): string | null {
|
||||
const mask = this.opts.mask
|
||||
|
||||
if (mask == null) {
|
||||
return null
|
||||
} else if (typeof mask === 'string') {
|
||||
return mask
|
||||
} else if (typeof mask === 'function') {
|
||||
return mask(value)
|
||||
}
|
||||
|
||||
const l = this.process(value, mask.slice(-1).pop() ?? '', false)
|
||||
|
||||
return mask.find((el) => this.process(value, el, false).length >= l.length) ?? ''
|
||||
}
|
||||
|
||||
private escapeMask (maskRaw: string): { mask: string, escaped: number[] } {
|
||||
const chars: string[] = []
|
||||
const escaped: number[] = []
|
||||
|
||||
maskRaw.split('').forEach((ch, i) => {
|
||||
if (ch === '!' && maskRaw[i - 1] !== '!') {
|
||||
escaped.push(i - escaped.length)
|
||||
} else {
|
||||
chars.push(ch)
|
||||
}
|
||||
})
|
||||
|
||||
return { mask: chars.join(''), escaped }
|
||||
}
|
||||
|
||||
private process (value: string, maskRaw: string | null, masked = true): string {
|
||||
if (maskRaw == null) return value
|
||||
|
||||
const memoKey = `v=${value},mr=${maskRaw},m=${masked ? 1 : 0}`
|
||||
|
||||
if (this.memo.has(memoKey)) return this.memo.get(memoKey)
|
||||
|
||||
const { mask, escaped } = this.escapeMask(maskRaw)
|
||||
const result: string[] = []
|
||||
const tokens = this.opts.tokens != null ? this.opts.tokens : {}
|
||||
const offset = this.isReversed() ? -1 : 1
|
||||
const method = this.isReversed() ? 'unshift' : 'push'
|
||||
const lastMaskChar = this.isReversed() ? 0 : mask.length - 1
|
||||
|
||||
const check = this.isReversed()
|
||||
? () => m > -1 && v > -1
|
||||
: () => m < mask.length && v < value.length
|
||||
|
||||
const notLastMaskChar = (m: number): boolean =>
|
||||
(!this.isReversed() && m <= lastMaskChar) ||
|
||||
(this.isReversed() && m >= lastMaskChar)
|
||||
|
||||
let lastRawMaskChar
|
||||
let repeatedPos = -1
|
||||
let m = this.isReversed() ? mask.length - 1 : 0
|
||||
let v = this.isReversed() ? value.length - 1 : 0
|
||||
let multipleMatched = false
|
||||
|
||||
while (check()) {
|
||||
const maskChar = mask.charAt(m)
|
||||
const token = tokens[maskChar]
|
||||
const valueChar = token?.transform != null
|
||||
? token.transform(value.charAt(v))
|
||||
: value.charAt(v)
|
||||
|
||||
// mask symbol is token
|
||||
if (!escaped.includes(m) && token != null) {
|
||||
// value symbol matched token
|
||||
if (valueChar.match(token.pattern) != null) {
|
||||
result[method](valueChar)
|
||||
|
||||
if (token.repeated as boolean) {
|
||||
if (repeatedPos === -1) {
|
||||
repeatedPos = m
|
||||
} else if (m === lastMaskChar && m !== repeatedPos) {
|
||||
m = repeatedPos - offset
|
||||
}
|
||||
|
||||
if (lastMaskChar === repeatedPos) {
|
||||
m -= offset
|
||||
}
|
||||
} else if (token.multiple as boolean) {
|
||||
multipleMatched = true
|
||||
m -= offset
|
||||
}
|
||||
|
||||
m += offset
|
||||
} else if (token.multiple as boolean) {
|
||||
if (multipleMatched) {
|
||||
m += offset
|
||||
v -= offset
|
||||
multipleMatched = false
|
||||
} else {
|
||||
// invalid input
|
||||
}
|
||||
} else if (valueChar === lastRawMaskChar) {
|
||||
// matched the last untranslated (raw) mask character that we encountered
|
||||
// likely an insert offset the mask character from the last entry;
|
||||
// fall through and only increment v
|
||||
lastRawMaskChar = undefined
|
||||
} else if (token.optional as boolean) {
|
||||
m += offset
|
||||
v -= offset
|
||||
} else {
|
||||
// invalid input
|
||||
}
|
||||
|
||||
v += offset
|
||||
} else { // mask symbol is placeholder
|
||||
if (masked && !this.isEager()) {
|
||||
result[method](maskChar)
|
||||
}
|
||||
|
||||
if (valueChar === maskChar && !this.isEager()) {
|
||||
v += offset
|
||||
} else {
|
||||
lastRawMaskChar = maskChar
|
||||
}
|
||||
|
||||
if (!this.isEager()) {
|
||||
m += offset
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isEager()) {
|
||||
// fill up result with placeholder symbols
|
||||
while (notLastMaskChar(m) && (tokens[mask.charAt(m)] == null || escaped.includes(m))) {
|
||||
if (masked) {
|
||||
result[method](mask.charAt(m))
|
||||
|
||||
if (value.charAt(v) === mask.charAt(m)) {
|
||||
m += offset
|
||||
v += offset
|
||||
continue
|
||||
}
|
||||
} else if (mask.charAt(m) === value.charAt(v)) {
|
||||
v += offset
|
||||
}
|
||||
|
||||
m += offset
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.memo.set(memoKey, result.join(''))
|
||||
|
||||
return this.memo.get(memoKey)
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { MaskOptions, MaskType } from './mask'
|
||||
import { MaskTokens } from './tokens'
|
||||
|
||||
const parseJson = (value: string): any => JSON.parse(value.replaceAll("'", '"'))
|
||||
|
||||
export const parseInput = (
|
||||
input: HTMLInputElement,
|
||||
defaults: MaskOptions = {}
|
||||
): MaskOptions => {
|
||||
const opts = { ...defaults }
|
||||
|
||||
if (input.dataset.maska != null && input.dataset.maska !== '') {
|
||||
opts.mask = parseMask(input.dataset.maska)
|
||||
}
|
||||
if (input.dataset.maskaEager != null) {
|
||||
opts.eager = parseOpts(input.dataset.maskaEager)
|
||||
}
|
||||
if (input.dataset.maskaReversed != null) {
|
||||
opts.reversed = parseOpts(input.dataset.maskaReversed)
|
||||
}
|
||||
if (input.dataset.maskaTokensReplace != null) {
|
||||
opts.tokensReplace = parseOpts(input.dataset.maskaTokensReplace)
|
||||
}
|
||||
if (input.dataset.maskaTokens != null) {
|
||||
opts.tokens = parseTokens(input.dataset.maskaTokens)
|
||||
}
|
||||
|
||||
return opts
|
||||
}
|
||||
|
||||
const parseOpts = (value: string): boolean =>
|
||||
value !== '' ? Boolean(JSON.parse(value)) : true
|
||||
|
||||
const parseMask = (value: string): MaskType =>
|
||||
value.startsWith('[') && value.endsWith(']') ? parseJson(value) : value
|
||||
|
||||
const parseTokens = (value: string): MaskTokens => {
|
||||
if (value.startsWith('{') && value.endsWith('}')) {
|
||||
return parseJson(value)
|
||||
}
|
||||
|
||||
const tokens: MaskTokens = {}
|
||||
value.split('|').forEach((token) => {
|
||||
const parts = token.split(':')
|
||||
tokens[parts[0]] = {
|
||||
pattern: new RegExp(parts[1]),
|
||||
optional: parts[2] === 'optional',
|
||||
multiple: parts[2] === 'multiple',
|
||||
repeated: parts[2] === 'repeated'
|
||||
}
|
||||
})
|
||||
|
||||
return tokens
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
interface MaskToken {
|
||||
pattern: RegExp
|
||||
multiple?: boolean
|
||||
optional?: boolean
|
||||
repeated?: boolean
|
||||
transform?: (char: string) => string
|
||||
}
|
||||
|
||||
export type MaskTokens = Record<string, MaskToken>
|
||||
|
||||
export const tokens: MaskTokens = {
|
||||
'#': { pattern: /[0-9]/ },
|
||||
'@': { pattern: /[a-zA-Z]/ },
|
||||
'*': { pattern: /[a-zA-Z0-9]/ }
|
||||
}
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
import { Directive, DirectiveBinding } from 'vue'
|
||||
import { MaskaDetail, MaskInput, MaskInputOptions } from './input'
|
||||
|
||||
type MaskaDirective = Directive<HTMLElement, MaskInputOptions | undefined>
|
||||
|
||||
const masks = new WeakMap<HTMLInputElement, MaskInput>()
|
||||
|
||||
// hacky way to update binding.arg without using defineExposed
|
||||
const setArg = (binding: DirectiveBinding, value: string | boolean): void => {
|
||||
if (binding.arg == null || (binding.instance == null)) return
|
||||
|
||||
const inst = binding.instance as any
|
||||
|
||||
if (binding.arg in inst) {
|
||||
inst[binding.arg] = value // options api
|
||||
} else if (inst.$?.setupState != null && binding.arg in inst.$.setupState) {
|
||||
inst.$.setupState[binding.arg] = value // composition api
|
||||
}
|
||||
}
|
||||
|
||||
export const vMaska: MaskaDirective = (el, binding) => {
|
||||
const input = el instanceof HTMLInputElement ? el : el.querySelector('input')
|
||||
const opts = binding.value != null ? { ...binding.value } : {}
|
||||
|
||||
if (input == null || input?.type === 'file') return
|
||||
|
||||
if (binding.arg != null) {
|
||||
const updateArg = (detail: MaskaDetail): void => {
|
||||
const value = binding.modifiers.unmasked
|
||||
? detail.unmasked
|
||||
: binding.modifiers.completed
|
||||
? detail.completed
|
||||
: detail.masked
|
||||
|
||||
setArg(binding, value)
|
||||
}
|
||||
|
||||
opts.onMaska =
|
||||
opts.onMaska == null
|
||||
? updateArg
|
||||
: Array.isArray(opts.onMaska)
|
||||
? [...opts.onMaska, updateArg]
|
||||
: [opts.onMaska, updateArg]
|
||||
}
|
||||
|
||||
let mask = masks.get(input)
|
||||
if (mask != null) {
|
||||
mask.update(opts)
|
||||
} else {
|
||||
mask = new MaskInput(input, opts)
|
||||
masks.set(input, mask)
|
||||
}
|
||||
|
||||
// delay for possible v-model change
|
||||
queueMicrotask(() => mask?.updateValue(input))
|
||||
}
|
||||
Reference in New Issue
Block a user