Feature(custom): support jxl image preview in gallery

ISSUES CLOSED: #531
This commit is contained in:
Kuingsmile
2026-06-11 23:32:18 -07:00
parent 74cf3ea1ad
commit 26d9c4906d
11 changed files with 507 additions and 17 deletions

View File

@@ -6,7 +6,6 @@
"esbenp.prettier-vscode",
"EditorConfig.EditorConfig",
"lokalise.i18n-ally",
"bradlc.vscode-tailwindcss",
"vitest.explorer"
"bradlc.vscode-tailwindcss"
]
}

View File

@@ -82,6 +82,7 @@
"got": "^14.6.6",
"hpagent": "^1.2.0",
"i18next": "^26.3.1",
"jxl-oxide-wasm": "0.12.6",
"lodash-es": "^4.18.1",
"marked": "^18.0.5",
"mime": "^4.1.0",

View File

@@ -5,6 +5,7 @@ import { clipboard } from 'electron'
import { RPCRouter } from '~/events/rpc/router'
import { ICOREBuildInEvent, IPasteStyle, IRPCActionType, IRPCType } from '~/utils/enum'
import { convertJxlSourceToPngDataUrl } from '~/utils/jxlPreview'
import pasteTemplate from '~/utils/pasteTemplate'
import { runScriptInStage } from '~/utils/runScript'
interface IFilter {
@@ -49,6 +50,17 @@ const galleryRoutes = [
await runScriptInStage('onGalleryRemove', picgo, { galleryItem: args[0] })
},
},
{
action: IRPCActionType.GALLERY_GET_JXL_PREVIEW,
handler: async (_: IIPCEvent, args: [source: string, isKnownJxl?: boolean]) => {
try {
return await convertJxlSourceToPngDataUrl(args[0], args[1])
} catch (_e) {
return undefined
}
},
type: IRPCType.INVOKE,
},
{
action: IRPCActionType.GALLERY_GET_DB,
handler: async (_: IIPCEvent, args: [filter: IFilter]) => {

View File

@@ -206,6 +206,7 @@ export const IRPCActionType = {
GALLERY_INSERT_DB: 'GALLERY_INSERT_DB',
GALLERY_INSERT_DB_BATCH: 'GALLERY_INSERT_DB_BATCH',
GALLERY_REMOVE_RUN_SCRIPTS: 'GALLERY_REMOVE_RUN_SCRIPTS',
GALLERY_GET_JXL_PREVIEW: 'GALLERY_GET_JXL_PREVIEW',
// plugin rpc
PLUGIN_GET_LIST: 'PLUGIN_GET_LIST',

View File

@@ -0,0 +1,74 @@
import { readFileSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { initSync, JxlImage } from 'jxl-oxide-wasm'
let isJxlDecoderInitialized = false
function isHttpSource(source: string): boolean {
return /^https?:\/\//i.test(source)
}
function isInlineSource(source: string): boolean {
return /^(data:|blob:)/i.test(source)
}
function isJxlSource(source: string): boolean {
const sourcePath = isHttpSource(source) ? new URL(source).pathname : source
return path.extname(sourcePath.split(/[?#]/, 1)[0]).toLowerCase() === '.jxl'
}
function normalizeLocalFilePath(source: string): string {
if (source.startsWith('file://')) {
return fileURLToPath(source)
}
return source
}
function initJxlDecoder() {
if (isJxlDecoderInitialized) return
const require = createRequire(import.meta.url)
const packageJsonPath = require.resolve('jxl-oxide-wasm/package.json')
const wasmPath = path.join(path.dirname(packageJsonPath), 'jxl_oxide_wasm_bg.wasm')
const wasmBytes = readFileSync(wasmPath)
initSync({ module: wasmBytes })
isJxlDecoderInitialized = true
}
async function readJxlSource(source: string): Promise<Buffer> {
if (isHttpSource(source)) {
const response = await fetch(source)
if (!response.ok) {
throw new Error(`request failed with status ${response.status}`)
}
return Buffer.from(await response.arrayBuffer())
}
return await readFile(normalizeLocalFilePath(source))
}
export async function convertJxlSourceToPngDataUrl(source: string, isKnownJxl = false): Promise<string | undefined> {
if (!source || isInlineSource(source) || (!isKnownJxl && !isJxlSource(source))) return undefined
const fileBytes = await readJxlSource(source)
initJxlDecoder()
const image = new JxlImage()
try {
image.feedBytes(fileBytes)
image.forceSrgb = true
if (!image.tryInit() || !image.loaded) {
return undefined
}
const pngBytes = image.render().encodeToPng()
return `data:image/png;base64,${Buffer.from(pngBytes).toString('base64')}`
} finally {
image.free()
}
}

View File

@@ -24,7 +24,7 @@
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue'
import { useVirtualGrid } from '@/hooks/useVirtualGrid'
@@ -51,6 +51,8 @@ const {
viewMode?: 'list' | 'grid'
}>()
const emit = defineEmits<(e: 'visibleIndexesChange', indexes: number[]) => void>()
const containerRef = useTemplateRef('containerRef')
const containerHeight = ref(0)
const containerWidth = ref<number>(0)
@@ -99,6 +101,14 @@ const viewportStyle = computed(() => {
const itemStyle = computed(() => (isGridMode.value ? {} : { height: `${itemHeight}px` }))
watch(
visibleIndexes,
indexes => {
emit('visibleIndexesChange', indexes)
},
{ immediate: true, flush: 'post' },
)
function handleScroll() {
const c = containerRef.value
if (!c) return

View File

@@ -209,6 +209,7 @@
:item-height="300"
:grid-breakpoints="effectiveGridBreakpoints"
key-field="key"
@visible-indexes-change="handleVisibleIndexesChange"
>
<template #default="{ item, index }">
<div
@@ -221,17 +222,11 @@
@click.stop="zoomImage(index)"
>
<img
:src="
imageErrorStates[item.key || '']
? './errorLoading.png'
: isAlwaysForceReload
? addCacheBustParam(item.src)
: item.src
"
:src="displayImageSources[item.key || ''] || item.src"
class="h-full w-full object-contain transition-all duration-fast ease-apple"
:class="{ loading: !imageLoadStates[item.key || ''] }"
@load="onImageLoad(item.key || '')"
@error="onImageError(item.key || '')"
@load="onImageLoad(item)"
@error="onImageError(item)"
/>
<div
v-if="!imageLoadStates[item.key || '']"
@@ -293,7 +288,7 @@
<!-- Custom Image Preview Modal -->
<ImagePreview
v-model:gallery-slider-control="gallerySliderControl"
:filter-list="filterList"
:filter-list="previewFilterList"
:is-always-force-reload="isAlwaysForceReload"
/>
@@ -436,6 +431,7 @@ import { configPaths } from '@/utils/configPaths'
import { getConfig, saveConfig } from '@/utils/dataSender'
import $$db from '@/utils/db'
import { IPasteStyle, IRPCActionType } from '@/utils/enum'
import { getGalleryPreviewSource, getJxlPreviewSource } from '@/utils/galleryPreview'
import { picBedsCanbeDeleted } from '@/utils/static'
type IResult<T> = T & {
@@ -493,6 +489,16 @@ const currentSortField = ref<'name' | 'time' | 'ext' | 'check'>('name')
const userGridColumns = useStorage<number>('galleryGridColumns', 4)
const imageLoadStates = reactive<Record<string, boolean>>({})
const imageErrorStates = reactive<Record<string, boolean>>({})
const displayImageSources = reactive<Record<string, string>>({})
const jxlPreviewCache = reactive<Record<string, string>>({})
const jxlPreviewLoading = reactive<Record<string, boolean>>({})
const jxlPreviewErrors = reactive<Record<string, boolean>>({})
const jxlPreviewCacheOrder: string[] = []
const cacheBustToken = ref(Date.now())
const visibleGalleryIndexes = ref<number[]>([])
let jxlPreviewGeneration = 0
const JXL_PREVIEW_CACHE_LIMIT = 64
const pasteStyleList = ['markdown', 'HTML', 'URL', 'UBB', 'Custom']
const shortURLList = [t('pages.gallery.shortUrl'), t('pages.gallery.longUrl')]
@@ -556,6 +562,17 @@ const filterList = computed(() => {
return getGallery()
})
const previewFilterList = computed(() => {
if (!gallerySliderControl.value.visible) {
return filterList.value
}
const currentIndex = gallerySliderControl.value.index
return filterList.value.map((item, index) =>
index === currentIndex ? { ...item, src: buildDisplayImageSrc(item) } : item,
)
})
const matchedUrls = computed(() => {
const matches = filterList.value.filter((item: any) => {
return customStrMatch(item.imgUrl, batchRenameMatch.value)
@@ -597,8 +614,28 @@ watch(useShortUrl, newVal => {
saveConfig(configPaths.settings.useShortUrl, newVal === t('pages.gallery.shortUrl'))
})
watch(filterList, () => {
watch(filterList, items => {
clearChoosedList()
pruneDisplayImageSources(items)
pruneJxlPreviewState(items)
nextTick(() => {
syncVisibleDisplayImageSources()
})
})
watch(
() => [gallerySliderControl.value.visible, gallerySliderControl.value.index] as const,
([visible, index]) => {
if (visible) {
ensureJxlPreview(filterList.value[index])
}
},
)
watch(isAlwaysForceReload, () => {
cacheBustToken.value = Date.now()
invalidateJxlPreviewCache()
syncVisibleDisplayImageSources()
})
watch(userGridColumns, _ => {
@@ -629,13 +666,23 @@ watch(searchTextURL, newVal => {
}, 300)
})
function onImageLoad(id: string) {
function onImageLoad(item: IGalleryItem) {
const id = item.key || ''
imageLoadStates[id] = true
if (getJxlPreviewSource(item)) {
updateDisplayImageSource(item)
}
}
function onImageError(id: string) {
function onImageError(item: IGalleryItem) {
const id = item.key || ''
imageLoadStates[id] = false
if (ensureJxlPreview(item)) {
return
}
imageErrorStates[id] = true
updateDisplayImageSource(item)
}
function toggleViewMode() {
@@ -689,7 +736,7 @@ const addCacheBustParam = (url: string | undefined) => {
}
try {
const separator = url.includes('?') ? '&' : '?'
return `${url}${separator}cbplist=${new Date().getTime()}`
return `${url}${separator}cbplist=${cacheBustToken.value}`
} catch (e) {
return url
}
@@ -699,6 +746,186 @@ function formatFileName(name: string) {
return window.node.path.basename(name)
}
function getPreviewSource(item: ImgInfo) {
const itemKey = item.key || ''
const previewPath = getJxlPreviewSource(item)
if (previewPath && jxlPreviewCache[previewPath]) {
touchJxlPreviewCache(previewPath)
}
return getGalleryPreviewSource(
item,
jxlPreviewCache,
jxlPreviewLoading,
jxlPreviewErrors,
itemKey ? imageLoadStates[itemKey] : false,
)
}
function buildDisplayImageSrc(item: IGalleryItem) {
if (imageErrorStates[item.key || '']) return './errorLoading.png'
const src = getJxlPreviewSource(item) ? getPreviewSource(item) : item.src || item.galleryPath || item.imgUrl || ''
return isAlwaysForceReload.value ? addCacheBustParam(src) : src
}
function updateDisplayImageSource(item?: IGalleryItem) {
if (!item?.key) return
if (!filterList.value.some(currentItem => currentItem.key === item.key)) return
displayImageSources[item.key] = buildDisplayImageSrc(item)
}
function pruneDisplayImageSources(items: IGalleryItem[] = filterList.value, indexes?: number[]) {
const keys = new Set<string>()
const sourceItems = indexes ? indexes.map(index => items[index]).filter(Boolean) : items
sourceItems.forEach(item => {
if (!item.key) return
keys.add(item.key)
})
Object.keys(displayImageSources).forEach(key => {
if (!keys.has(key)) {
delete displayImageSources[key]
}
})
}
function syncVisibleDisplayImageSources(indexes: number[] = visibleGalleryIndexes.value) {
const visibleItems = filterList.value
pruneDisplayImageSources(visibleItems, indexes)
indexes.forEach(index => {
updateDisplayImageSource(visibleItems[index])
})
}
function getActiveJxlPreviewSources(items: ImgInfo[] = filterList.value) {
const sources = new Set<string>()
items.forEach(item => {
const previewPath = getJxlPreviewSource(item)
if (previewPath) {
sources.add(previewPath)
}
})
return sources
}
function isJxlPreviewSourceActive(previewPath: string) {
return getActiveJxlPreviewSources().has(previewPath)
}
function touchJxlPreviewCache(previewPath: string) {
const index = jxlPreviewCacheOrder.indexOf(previewPath)
if (index >= 0) {
jxlPreviewCacheOrder.splice(index, 1)
}
jxlPreviewCacheOrder.push(previewPath)
}
function deleteJxlPreviewCacheEntry(previewPath: string) {
delete jxlPreviewCache[previewPath]
const index = jxlPreviewCacheOrder.indexOf(previewPath)
if (index >= 0) {
jxlPreviewCacheOrder.splice(index, 1)
}
}
function cacheJxlPreview(previewPath: string, previewSrc: string) {
jxlPreviewCache[previewPath] = previewSrc
touchJxlPreviewCache(previewPath)
while (jxlPreviewCacheOrder.length > JXL_PREVIEW_CACHE_LIMIT) {
const stalePreviewPath = jxlPreviewCacheOrder.shift()
if (stalePreviewPath) {
delete jxlPreviewCache[stalePreviewPath]
}
}
}
function invalidateJxlPreviewCache() {
jxlPreviewGeneration += 1
jxlPreviewCacheOrder.length = 0
Object.keys(jxlPreviewCache).forEach(key => {
delete jxlPreviewCache[key]
})
Object.keys(jxlPreviewLoading).forEach(key => {
delete jxlPreviewLoading[key]
})
Object.keys(jxlPreviewErrors).forEach(key => {
delete jxlPreviewErrors[key]
})
}
function pruneJxlPreviewState(items: ImgInfo[] = filterList.value) {
const activeSources = getActiveJxlPreviewSources(items)
Object.keys(jxlPreviewCache).forEach(previewPath => {
if (!activeSources.has(previewPath)) {
deleteJxlPreviewCacheEntry(previewPath)
}
})
Object.keys(jxlPreviewLoading).forEach(previewPath => {
if (!activeSources.has(previewPath)) {
delete jxlPreviewLoading[previewPath]
}
})
Object.keys(jxlPreviewErrors).forEach(previewPath => {
if (!activeSources.has(previewPath)) {
delete jxlPreviewErrors[previewPath]
}
})
}
function handleVisibleIndexesChange(indexes: number[]) {
visibleGalleryIndexes.value = indexes
syncVisibleDisplayImageSources(indexes)
}
function ensureJxlPreview(item?: IGalleryItem): boolean {
const previewPath = getJxlPreviewSource(item)
if (!previewPath || jxlPreviewErrors[previewPath]) {
return false
}
if (jxlPreviewCache[previewPath]) {
updateDisplayImageSource(item)
return true
}
if (jxlPreviewLoading[previewPath]) {
return true
}
jxlPreviewLoading[previewPath] = true
const previewGeneration = jxlPreviewGeneration
const previewRequestSource = isAlwaysForceReload.value ? addCacheBustParam(previewPath) : previewPath
updateDisplayImageSource(item)
window.electron
.triggerRPC<string | undefined>(IRPCActionType.GALLERY_GET_JXL_PREVIEW, previewRequestSource, true)
.then(previewSrc => {
if (previewGeneration !== jxlPreviewGeneration || !isJxlPreviewSourceActive(previewPath)) {
return
}
if (previewSrc) {
cacheJxlPreview(previewPath, previewSrc)
delete jxlPreviewErrors[previewPath]
} else {
jxlPreviewErrors[previewPath] = true
}
})
.catch(() => {
if (previewGeneration !== jxlPreviewGeneration || !isJxlPreviewSourceActive(previewPath)) {
return
}
jxlPreviewErrors[previewPath] = true
})
.finally(() => {
if (previewGeneration !== jxlPreviewGeneration) {
return
}
delete jxlPreviewLoading[previewPath]
updateDisplayImageSource(item)
})
return true
}
function getGallery(): IGalleryItem[] {
if (
debouncedSearchText.value ||
@@ -760,8 +987,17 @@ async function updateGallery() {
Object.keys(imageErrorStates).forEach(k => {
if (!newIds.has(k)) delete imageErrorStates[k]
})
Object.keys(displayImageSources).forEach(k => {
if (!newIds.has(k)) delete displayImageSources[k]
})
if (isAlwaysForceReload.value) {
cacheBustToken.value = Date.now()
invalidateJxlPreviewCache()
}
images.value = newList
nextTick(() => {
pruneJxlPreviewState()
syncVisibleDisplayImageSources()
if (virtualScrollerRef.value) {
virtualScrollerRef.value.refresh()
}
@@ -806,6 +1042,7 @@ function clearChoosedList() {
}
function zoomImage(index: number) {
ensureJxlPreview(filterList.value[index])
gallerySliderControl.value.index = index
gallerySliderControl.value.visible = true
}

View File

@@ -148,6 +148,7 @@ export const IRPCActionType = {
GALLERY_INSERT_DB: 'GALLERY_INSERT_DB',
GALLERY_INSERT_DB_BATCH: 'GALLERY_INSERT_DB_BATCH',
GALLERY_REMOVE_RUN_SCRIPTS: 'GALLERY_REMOVE_RUN_SCRIPTS',
GALLERY_GET_JXL_PREVIEW: 'GALLERY_GET_JXL_PREVIEW',
// plugin rpc
PLUGIN_GET_LIST: 'PLUGIN_GET_LIST',

View File

@@ -0,0 +1,79 @@
type GalleryPreviewItem = Pick<ImgInfo, 'extname' | 'fileName' | 'imgUrl'> & {
galleryPath?: string
}
export const JXL_LOADING_PREVIEW_SRC = './loading.jpg'
export const JXL_ERROR_PREVIEW_SRC = './errorLoading.png'
function stripUrlSuffix(value: string): string {
return value.split(/[?#]/, 1)[0]
}
function getSourceExtension(value?: string): string {
if (!value) return ''
const match = stripUrlSuffix(value).match(/\.([a-z0-9]+)$/i)
return match ? `.${match[1].toLowerCase()}` : ''
}
function hasJxlExtension(value?: string): boolean {
return getSourceExtension(value) === '.jxl'
}
function hasNonJxlExtension(value?: string): boolean {
const extension = getSourceExtension(value)
return extension !== '' && extension !== '.jxl'
}
function normalizeExtname(extname?: string): string {
if (!extname) return ''
const normalized = extname.toLowerCase()
return normalized.startsWith('.') ? normalized : `.${normalized}`
}
function isInlineImageSource(value: string): boolean {
return /^(data:|blob:)/i.test(value)
}
export function getJxlPreviewSource(item?: GalleryPreviewItem): string {
const hasJxlMetadata = hasJxlExtension(item?.fileName) || normalizeExtname(item?.extname) === '.jxl'
const previewSources = [item?.galleryPath, item?.imgUrl].filter(Boolean) as string[]
const usableSources = previewSources.filter(source => !isInlineImageSource(source))
const explicitJxlSource = usableSources.find(source => hasJxlExtension(source))
if (explicitJxlSource) return explicitJxlSource
if (!hasJxlMetadata) return ''
return (
[item?.imgUrl, item?.galleryPath].find(
source => source && !isInlineImageSource(source) && !hasNonJxlExtension(source),
) || ''
)
}
function getNativeFallbackSource(item: GalleryPreviewItem, jxlPreviewSource: string): string {
const fallbackSrc = item.galleryPath || item.imgUrl || ''
if (!fallbackSrc || fallbackSrc === jxlPreviewSource || hasJxlExtension(fallbackSrc)) return ''
return fallbackSrc
}
export function getGalleryPreviewSource(
item: GalleryPreviewItem,
jxlPreviews: Record<string, string>,
jxlPreviewLoading: Record<string, boolean> = {},
jxlPreviewErrors: Record<string, boolean> = {},
preferNativeFallbackOnError = false,
): string {
const fallbackSrc = item.galleryPath || item.imgUrl || ''
const jxlPreviewSource = getJxlPreviewSource(item)
if (!jxlPreviewSource) return fallbackSrc
const nativeFallbackSrc = getNativeFallbackSource(item, jxlPreviewSource)
if (jxlPreviews[jxlPreviewSource]) return jxlPreviews[jxlPreviewSource]
if (jxlPreviewErrors[jxlPreviewSource]) {
if (preferNativeFallbackOnError) return fallbackSrc
return nativeFallbackSrc || JXL_ERROR_PREVIEW_SRC
}
if (jxlPreviewLoading[jxlPreviewSource]) return nativeFallbackSrc || JXL_LOADING_PREVIEW_SRC
return fallbackSrc
}

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest'
import {
getGalleryPreviewSource,
getJxlPreviewSource,
JXL_ERROR_PREVIEW_SRC,
JXL_LOADING_PREVIEW_SRC,
} from '../src/renderer/utils/galleryPreview'
describe('gallery JXL preview helpers', () => {
it('detects local JPEG XL files from galleryPath, fileName, or extname', () => {
expect(getJxlPreviewSource({ galleryPath: 'C:\\images\\photo.jxl' })).toBe('C:\\images\\photo.jxl')
expect(getJxlPreviewSource({ galleryPath: '/tmp/photo', fileName: 'photo.JXL' })).toBe('/tmp/photo')
expect(getJxlPreviewSource({ galleryPath: '/tmp/photo', extname: 'jxl' })).toBe('/tmp/photo')
})
it('detects remote JPEG XL URLs but ignores inline image sources', () => {
expect(getJxlPreviewSource({ imgUrl: 'https://example.com/photo.jxl' })).toBe('https://example.com/photo.jxl')
expect(getJxlPreviewSource({ galleryPath: 'data:image/jxl;base64,abc', extname: '.jxl' })).toBe('')
})
it('does not decode a non-JXL galleryPath just because metadata says JXL', () => {
expect(
getJxlPreviewSource({
galleryPath: '/tmp/original.png',
imgUrl: 'https://example.com/uploaded',
extname: '.jxl',
}),
).toBe('https://example.com/uploaded')
expect(
getJxlPreviewSource({
galleryPath: '/tmp/original.png',
extname: '.jxl',
}),
).toBe('')
})
it('uses cached, loading, and error preview sources for local JXL images', () => {
const item = { galleryPath: '/tmp/photo.jxl', imgUrl: 'https://example.com/photo.jxl' }
expect(getGalleryPreviewSource(item, { '/tmp/photo.jxl': 'data:image/png;base64,abc' })).toBe(
'data:image/png;base64,abc',
)
expect(getGalleryPreviewSource(item, {}, { '/tmp/photo.jxl': true })).toBe(JXL_LOADING_PREVIEW_SRC)
expect(getGalleryPreviewSource(item, {}, {}, { '/tmp/photo.jxl': true })).toBe(JXL_ERROR_PREVIEW_SRC)
})
it('can keep the original JXL source after decoder failure when native rendering already worked', () => {
const item = { galleryPath: '/tmp/photo.jxl', imgUrl: 'https://example.com/photo.jxl' }
expect(getGalleryPreviewSource(item, {}, {}, { '/tmp/photo.jxl': true }, true)).toBe('/tmp/photo.jxl')
})
it('keeps a renderable gallery fallback while a separate JXL source is loading or failed', () => {
const item = {
galleryPath: '/tmp/original.png',
imgUrl: 'https://example.com/photo.jxl',
extname: '.jxl',
}
expect(getGalleryPreviewSource(item, {}, { 'https://example.com/photo.jxl': true })).toBe('/tmp/original.png')
expect(getGalleryPreviewSource(item, {}, {}, { 'https://example.com/photo.jxl': true })).toBe('/tmp/original.png')
})
it('falls back to the original source for non-JXL images', () => {
expect(
getGalleryPreviewSource({ galleryPath: '/tmp/photo.png', imgUrl: 'https://example.com/photo.png' }, {}),
).toBe('/tmp/photo.png')
})
})

View File

@@ -9107,6 +9107,11 @@ jszip@^3.1.0:
readable-stream "~2.3.6"
setimmediate "^1.0.5"
jxl-oxide-wasm@0.12.6:
version "0.12.6"
resolved "https://registry.yarnpkg.com/jxl-oxide-wasm/-/jxl-oxide-wasm-0.12.6.tgz#a5096bb51fb7c2a7dfac69576c7dae3a94c488ca"
integrity sha512-nZlPNSJidFAjkTqNcxxqdac/DxY0oNqr/Q98xOb3UutQyvopHjyubnW7KCjvwFuAr3Lew6xRe+7/zZ8tkbFxug==
keycode@2.2.0:
version "2.2.0"
resolved "https://registry.npmjs.org/keycode/-/keycode-2.2.0.tgz#3d0af56dc7b8b8e5cba8d0a97f107204eec22b04"