2
0
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:
Alexander Shabunevich
2024-04-15 22:07:47 +03:00
parent 685f477fe1
commit 4de44ef888
58 changed files with 1173 additions and 225 deletions
-7
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
}
-54
View File
@@ -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
}
-15
View File
@@ -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
View File
@@ -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))
}