refactor: remove swiper

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2024-11-15 23:29:14 +08:00
parent 7c1a7293df
commit 54ae2fe39f
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
14 changed files with 226 additions and 178 deletions

View File

@ -49,6 +49,8 @@
"dexie-export-import": "^4.1.2",
"dnum": "^2.14.0",
"electron-log": "5.2.0",
"embla-carousel-react": "8.3.1",
"embla-carousel-wheel-gestures": "8.0.1",
"firebase": "10.14.1",
"foxact": "0.2.41",
"framer-motion": "11.11.11",
@ -97,7 +99,6 @@
"remark-rehype": "11.1.1",
"shiki": "1.22.2",
"sonner": "1.5.0",
"swiper": "11.1.14",
"tldts": "6.1.58",
"unified": "11.0.5",
"unist-util-visit-parents": "^6.0.1",
@ -114,6 +115,7 @@
"@follow/components": "workspace:*",
"@follow/constants": "workspace:*",
"@follow/hooks": "workspace:*",
"@follow/logger": "workspace:*",
"@follow/models": "workspace:*",
"@follow/shared": "workspace:*",
"@follow/types": "workspace:*",

View File

@ -1,34 +1,25 @@
import "swiper/css"
import "swiper/css/navigation"
import "swiper/css/scrollbar"
import type { MediaModel } from "@follow/shared/hono"
import { cn } from "@follow/utils/utils"
import { useHover } from "@use-gesture/react"
import useEmblaCarousel from "embla-carousel-react"
import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures"
import { uniqBy } from "lodash-es"
import { useRef, useState } from "react"
import { Mousewheel, Navigation, Scrollbar } from "swiper/modules"
import { Swiper, SwiperSlide } from "swiper/react"
import { useCallback, useRef } from "react"
import { Media } from "~/components/ui/media"
import styles from "./index.module.css"
const defaultProxySize = {
width: 600,
height: 0,
}
export function SwipeMedia({
media,
uniqueKey,
className,
imgClassName,
onPreview,
proxySize = defaultProxySize,
forceSwiper,
}: {
media?: MediaModel[] | null
uniqueKey?: string
className?: string
imgClassName?: string
onPreview?: (media: MediaModel[], index?: number) => void
@ -36,53 +27,37 @@ export function SwipeMedia({
width: number
height: number
}
forceSwiper?: boolean
}) {
const uniqMedia = uniqBy(media, "url")
const hoverRef = useRef<HTMLDivElement>(null)
const [enableSwipe, setEnableSwipe] = useState(!!forceSwiper)
useHover(
(event) => {
if (event.active) {
setEnableSwipe(event.active)
}
},
{
target: hoverRef,
enabled: !forceSwiper,
},
)
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true }, [WheelGesturesPlugin()])
const scrollPrev = useCallback(() => {
if (emblaApi) emblaApi.scrollPrev()
}, [emblaApi])
const scrollNext = useCallback(() => {
if (emblaApi) emblaApi.scrollNext()
}, [emblaApi])
if (!media) return null
return (
<div
ref={hoverRef}
className={cn(
"relative flex w-full items-center overflow-hidden",
styles["swipe-wrapper"],
className,
)}
>
{enableSwipe && (uniqMedia?.length || 0) > 1 ? (
<>
<Swiper
loop
navigation={{
prevEl: `#swiper-button-prev-${uniqueKey}`,
nextEl: `#swiper-button-next-${uniqueKey}`,
}}
scrollbar={{
hide: true,
}}
mousewheel={{
forceToAxis: true,
}}
modules={[Navigation, Scrollbar, Mousewheel]}
className="size-full"
>
{uniqMedia?.length ? (
<div ref={emblaRef} className="w-full overflow-hidden">
<div className="flex">
{uniqMedia?.slice(0, 5).map((med, i) => (
<SwiperSlide key={med.url}>
<div className="mr-2 w-full flex-none" key={med.url}>
<Media
className={cn(imgClassName, "size-full rounded-none")}
alt="cover"
@ -100,44 +75,28 @@ export function SwipeMedia({
showFallback={true}
fitContent
/>
</SwiperSlide>
</div>
))}
</Swiper>
<div
id={`swiper-button-prev-${uniqueKey}`}
className="swiper-button left-2"
onDoubleClick={(e) => e.stopPropagation()}
>
<i className="i-mgc-left-cute-fi" />
</div>
<div
id={`swiper-button-next-${uniqueKey}`}
className="swiper-button right-2"
onDoubleClick={(e) => e.stopPropagation()}
>
<i className="i-mgc-right-cute-fi" />
</div>
</>
) : uniqMedia?.length >= 1 ? (
<Media
onClick={(e) => {
e.stopPropagation()
onPreview?.(uniqMedia)
}}
cacheDimensions={uniqMedia[0].type === "photo"}
className="size-full rounded-none object-cover sm:transition-transform sm:duration-300 sm:ease-in-out sm:group-hover:scale-105"
alt="cover"
src={uniqMedia[0].url}
type={uniqMedia[0].type}
previewImageUrl={uniqMedia[0].preview_image_url}
loading="lazy"
proxy={proxySize}
showFallback={true}
height={uniqMedia[0].height}
width={uniqMedia[0].width}
blurhash={uniqMedia[0].blurhash}
fitContent
/>
{emblaApi?.canScrollPrev() && (
<button
type="button"
className="center absolute left-2 top-1/2 size-6 -translate-y-1/2 rounded-full bg-gray-800 text-white opacity-0 duration-200 group-hover:opacity-100"
onClick={scrollPrev}
>
<i className="i-mingcute-arrow-left-line" />
</button>
)}
{emblaApi?.canScrollNext() && (
<button
type="button"
className="center absolute right-2 top-1/2 size-6 -translate-y-1/2 rounded-full bg-gray-800 text-white opacity-0 duration-200 group-hover:opacity-100"
onClick={scrollNext}
>
<i className="i-mingcute-arrow-right-line" />
</button>
)}
</div>
) : (
<div className="relative flex aspect-video w-full items-center overflow-hidden rounded-t-2xl border-b">
<div className="flex size-full items-center justify-center p-3 text-center sm:transition-transform sm:duration-500 sm:ease-in-out sm:group-hover:scale-105">

View File

@ -378,7 +378,7 @@ const PlayProgressBar = () => {
})
return (
<Slider.Root
className="swiper-no-swiping relative z-[1] flex size-full items-center transition-all duration-200 ease-in-out"
className="relative z-[1] flex size-full items-center transition-all duration-200 ease-in-out"
min={0}
max={state.duration}
step={0.01}

View File

@ -1,20 +0,0 @@
.swipe-wrapper {
:global {
.swiper {
@apply bg-theme-background;
}
.swiper-button {
@apply absolute top-1/2 -translate-y-1/2 bg-white rounded-full z-[1] w-6 h-6 flex items-center justify-center;
@apply transition-opacity opacity-0;
}
.swiper-wrapper {
@apply flex items-center;
}
}
&:hover :global(.swiper-button) {
@apply opacity-80;
}
}

View File

@ -5,14 +5,13 @@ import { IN_ELECTRON } from "@follow/shared/constants"
import type { MediaModel } from "@follow/shared/hono"
import { stopPropagation } from "@follow/utils/dom"
import { cn } from "@follow/utils/utils"
import useEmblaCarousel from "embla-carousel-react"
import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures"
import type { FC } from "react"
import { Fragment, useCallback, useEffect, useRef, useState } from "react"
import { Fragment, useCallback, useEffect, useState } from "react"
import { Blurhash } from "react-blurhash"
import { useTranslation } from "react-i18next"
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch"
import { Keyboard, Mousewheel } from "swiper/modules"
import type { SwiperRef } from "swiper/react"
import { Swiper, SwiperSlide } from "swiper/react"
import { useWindowSize } from "usehooks-ts"
import { m } from "~/components/common/Motion"
@ -115,10 +114,13 @@ export const PreviewMediaContent: FC<{
initialIndex?: number
children?: React.ReactNode
}> = ({ media, initialIndex = 0, children }) => {
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, startIndex: initialIndex }, [
WheelGesturesPlugin(),
])
const [currentMedia, setCurrentMedia] = useState(media[initialIndex])
const [currentSlideIndex, setCurrentSlideIndex] = useState(initialIndex)
const swiperRef = useRef<SwiperRef>(null)
// This only to delay show
const [currentSlideIndex, setCurrentSlideIndex] = useState(initialIndex)
const [showActions, setShowActions] = useState(false)
useEffect(() => {
@ -128,6 +130,31 @@ export const PreviewMediaContent: FC<{
return () => clearTimeout(timer)
}, [])
useEffect(() => {
if (emblaApi) {
emblaApi.on("select", () => {
const realIndex = emblaApi.selectedScrollSnap()
setCurrentMedia(media[realIndex])
setCurrentSlideIndex(realIndex)
})
}
}, [emblaApi, media])
const { ref } = useCurrentModal()
// Keyboard
useEffect(() => {
if (!emblaApi) return
const $container = ref.current
if (!$container) return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "ArrowLeft") emblaApi?.scrollPrev()
if (e.key === "ArrowRight") emblaApi?.scrollNext()
}
$container.addEventListener("keydown", handleKeyDown)
return () => $container.removeEventListener("keydown", handleKeyDown)
}, [emblaApi, ref])
if (media.length === 0) return null
if (media.length === 1) {
const src = media[0].url
@ -163,30 +190,44 @@ export const PreviewMediaContent: FC<{
const isVideo = currentMedia.type === "video"
return (
<Wrapper src={currentMedia.url} showActions={!isVideo} sideContent={children}>
<Swiper
ref={swiperRef}
loop
initialSlide={initialIndex}
mousewheel={{
forceToAxis: true,
}}
keyboard={{
enabled: true,
}}
onSlideChange={({ realIndex }) => {
setCurrentMedia(media[realIndex])
setCurrentSlideIndex(realIndex)
}}
modules={[Mousewheel, Keyboard]}
className="h-full w-auto"
>
<div className="size-full overflow-hidden" ref={emblaRef}>
<div className="flex size-full">
{media.map((med) => (
<div className="mr-2 flex w-full flex-none items-center justify-center" key={med.url}>
{med.type === "video" ? (
<VideoPlayer
src={med.url}
autoPlay
muted
controls
className="size-full object-contain"
onClick={(e) => e.stopPropagation()}
/>
) : (
<FallbackableImage
fallbackUrl={med.fallbackUrl}
className="size-full object-contain"
alt="cover"
src={med.url}
loading="lazy"
height={med.height}
width={med.width}
blurhash={med.blurhash}
haveSideContent={!!children}
/>
)}
</div>
))}
</div>
{showActions && (
<div tabIndex={-1} onClick={stopPropagation}>
<m.button
initial={{ opacity: 0, x: -20, scale: 0.94 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
initial={{ opacity: 0, transform: "translate3d(-20px, 0, 0) scale(0.94)" }}
animate={{ opacity: 1, transform: "translate3d(0, 0, 0) scale(1)" }}
transition={{ ease: "easeInOut", duration: 0.2 }}
onClick={() => swiperRef.current?.swiper.slidePrev()}
whileTap={{ transform: "translate3d(0, 0, 0) scale(0.9)" }}
onClick={() => emblaApi?.scrollPrev()}
type="button"
className="center absolute left-2 top-1/2 z-[99] size-8 -translate-y-1/2 rounded-full border border-white/20 bg-neutral-900/80 text-white backdrop-blur duration-200 hover:bg-neutral-900"
>
@ -194,10 +235,11 @@ export const PreviewMediaContent: FC<{
</m.button>
<m.button
initial={{ opacity: 0, x: 20, scale: 0.94 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
initial={{ opacity: 0, transform: "translate3d(20px, 0, 0) scale(0.94)" }}
animate={{ opacity: 1, transform: "translate3d(0, 0, 0) scale(1)" }}
transition={{ ease: "easeInOut", duration: 0.2 }}
onClick={() => swiperRef.current?.swiper.slideNext()}
whileTap={{ transform: "translate3d(0, 0, 0) scale(0.9)" }}
onClick={() => emblaApi?.scrollNext()}
type="button"
className="center absolute right-2 top-1/2 z-[99] size-8 -translate-y-1/2 rounded-full border border-white/20 bg-neutral-900/80 text-white backdrop-blur duration-200 hover:bg-neutral-900"
>
@ -229,7 +271,7 @@ export const PreviewMediaContent: FC<{
.map((_, index) => (
<button
onClick={() => {
swiperRef.current?.swiper.slideTo(index)
emblaApi?.scrollTo(index)
}}
type="button"
key={index}
@ -242,34 +284,7 @@ export const PreviewMediaContent: FC<{
</div>
</div>
)}
{media.map((med, index) => (
<SwiperSlide key={med.url} virtualIndex={index} className="center !flex">
{med.type === "video" ? (
<VideoPlayer
src={med.url}
autoPlay
muted
controls
className="size-full object-contain"
onClick={(e) => e.stopPropagation()}
/>
) : (
<FallbackableImage
fallbackUrl={med.fallbackUrl}
className="size-full object-contain"
alt="cover"
src={med.url}
loading="lazy"
height={med.height}
width={med.width}
blurhash={med.blurhash}
haveSideContent={!!children}
/>
)}
</SwiperSlide>
))}
</Swiper>
</div>
</Wrapper>
)
}

View File

@ -136,7 +136,7 @@ export const ModalInternal = memo(
const { noticeModal, animateController } = useModalAnimate(!!isTop)
const getIndex = useEventCallback(() => index)
const modalContentRef = useRef<HTMLDivElement>(null)
const [modalContentRef, setModalContentRef] = useState<HTMLDivElement | null>(null)
const ModalProps: ModalActionsInternal = useMemo(
() => ({
dismiss: close,
@ -159,9 +159,9 @@ export const ModalInternal = memo(
const ModalContextProps = useMemo<CurrentModalContentProps>(
() => ({
...ModalProps,
ref: modalContentRef,
ref: { current: modalContentRef },
}),
[ModalProps],
[ModalProps, modalContentRef],
)
const [edgeElementRef, setEdgeElementRef] = useState<HTMLDivElement | null>(null)
@ -239,6 +239,7 @@ export const ModalInternal = memo(
{Overlay}
<Dialog.DialogTitle className="sr-only">{title}</Dialog.DialogTitle>
<Dialog.Content
ref={setModalContentRef}
asChild
aria-describedby={undefined}
onPointerDownOutside={preventDefault}
@ -289,6 +290,7 @@ export const ModalInternal = memo(
<Dialog.Portal>
{Overlay}
<Dialog.Content
ref={setModalContentRef}
asChild
aria-describedby={undefined}
onPointerDownOutside={preventDefault}

View File

@ -1,4 +1,4 @@
import { log } from "electron-log"
import { log } from "@follow/logger"
export const appLog = (...args: any[]) => {
if (ELECTRON) log(...args)

View File

@ -50,7 +50,6 @@ export function PictureItem({ entryId, entryPreview, translation }: UniversalIte
isActive && "rounded-b-none",
)}
imgClassName="object-cover"
uniqueKey={entryId}
onPreview={(media, i) => {
previewMedia(media, i)
}}
@ -127,7 +126,6 @@ export const PictureWaterFallItem = memo(function PictureWaterFallItem({
className={cn("w-full shrink-0 grow rounded-md", isActive && "rounded-b-none")}
proxySize={proxySize}
imgClassName="object-cover"
uniqueKey={entryId}
onPreview={previewMedia}
/>

View File

@ -0,0 +1 @@
export { initialize, log } from "electron-log"

View File

@ -0,0 +1,14 @@
{
"name": "@follow/logger",
"version": "0.0.1",
"exports": {
".": {
"types": "./electron.ts",
"web": "./web.ts",
"default": "./electron.ts"
}
},
"dependencies": {
"electron-log": "5.2.2"
}
}

View File

@ -0,0 +1,13 @@
{
"extends": "../tsconfig.extend.json",
"compilerOptions": {
"noEmit": true,
"baseUrl": ".",
"jsx": "preserve",
"declaration": true,
"paths": {
"@pkg": ["../../package.json"]
}
},
"include": ["**/*"]
}

6
packages/logger/web.ts Normal file
View File

@ -0,0 +1,6 @@
export const log = (...args: any[]) => {
// eslint-disable-next-line no-console
console.log(...args)
}
export const initialize = () => {}

View File

@ -453,6 +453,12 @@ importers:
electron-log:
specifier: 5.2.0
version: 5.2.0
embla-carousel-react:
specifier: 8.3.1
version: 8.3.1(react@18.3.1)
embla-carousel-wheel-gestures:
specifier: 8.0.1
version: 8.0.1(embla-carousel@8.3.1)
firebase:
specifier: 10.14.1
version: 10.14.1
@ -597,9 +603,6 @@ importers:
sonner:
specifier: 1.5.0
version: 1.5.0(patch_hash=6s3tquyt5wnkqaogymn3mkivuq)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
swiper:
specifier: 11.1.14
version: 11.1.14
tldts:
specifier: 6.1.58
version: 6.1.58
@ -643,6 +646,9 @@ importers:
'@follow/hooks':
specifier: workspace:*
version: link:../../packages/hooks
'@follow/logger':
specifier: workspace:*
version: link:../../packages/logger
'@follow/models':
specifier: workspace:*
version: link:../../packages/models
@ -1032,6 +1038,12 @@ importers:
specifier: 3.1.0
version: 3.1.0(react@18.3.1)
packages/logger:
dependencies:
electron-log:
specifier: 5.2.2
version: 5.2.2
packages/models:
dependencies:
'@auth/core':
@ -6080,6 +6092,10 @@ packages:
resolution: {integrity: sha512-VjLkvaLmbP3AOGOh5Fob9M8bFU0mmeSAb5G2EoTBx+kQLf2XA/0byzjsVGBTHhikbT+m1AB27NEQUv9wX9nM8w==}
engines: {node: '>= 14'}
electron-log@5.2.2:
resolution: {integrity: sha512-fgvx6srjIHDowJD8WAAjoAXmiTyOz6JnGQoxOtk1mXw7o4S+HutuPHLCsk24xTXqWZgy4uO63NbedG+oEvldLw==}
engines: {node: '>= 14'}
electron-packager-languages@0.5.0:
resolution: {integrity: sha512-ryJsVXgHq0+7eZpJ+YSUQNYUnH4yPq2J4gXtmP9HEq8N6PHtygLEmohKMm4VrwI5qTir4HRCxy+O1UNo8mbwgg==}
engines: {node: '>6.0.0'}
@ -6125,6 +6141,25 @@ packages:
elliptic@6.5.7:
resolution: {integrity: sha512-ESVCtTwiA+XhY3wyh24QqRGBoP3rEdDUl3EDUUo9tft074fi19IrdpH7hLCMMP3CIj7jb3W96rn8lt/BqIlt5Q==}
embla-carousel-react@8.3.1:
resolution: {integrity: sha512-gBY0zM+2ASvKFwRpTIOn2SLifFqOKKap9R/y0iCpJWS3bc8OHVEn2gAThGYl2uq0N+hu9aBiswffL++OYZOmDQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
embla-carousel-reactive-utils@8.3.1:
resolution: {integrity: sha512-Js6rTTINNGnUGPu7l5kTcheoSbEnP5Ak2iX0G9uOoI8okTNLMzuWlEIpYFd1WP0Sq82FFcLkKM2oiO6jcElZ/Q==}
peerDependencies:
embla-carousel: 8.3.1
embla-carousel-wheel-gestures@8.0.1:
resolution: {integrity: sha512-LMAnruDqDmsjL6UoQD65aLotpmfO49Fsr3H0bMi7I+BH6jbv9OJiE61kN56daKsVtCQEt0SU1MrJslbhtgF3yQ==}
engines: {node: '>=10'}
peerDependencies:
embla-carousel: ^8.0.0 || ~8.0.0-rc03
embla-carousel@8.3.1:
resolution: {integrity: sha512-DutFjtEO586XptDn4cwvBJwsR/8fMa4jUk5Jk2g+/elKgu8mdn0Z2sx33g4JskvbLc1/6P8Xg4QlfELGJFcP5A==}
emoji-regex@10.4.0:
resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==}
@ -9916,10 +9951,6 @@ packages:
engines: {node: '>=14.0.0'}
hasBin: true
swiper@11.1.14:
resolution: {integrity: sha512-VbQLQXC04io6AoAjIUWuZwW4MSYozkcP9KjLdrsG/00Q/yiwvhz9RQyt0nHXV10hi9NVnDNy1/wv7Dzq1lkOCQ==}
engines: {node: '>= 4.7.0'}
synckit@0.6.2:
resolution: {integrity: sha512-Vhf+bUa//YSTYKseDiiEuQmhGCoIF3CVBhunm3r/DQnYiGT4JssmnKQc44BIyOZRK2pKjXXAgbhfmbeoC9CJpA==}
engines: {node: '>=12.20'}
@ -10617,6 +10648,10 @@ packages:
whatwg-url@7.1.0:
resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==}
wheel-gestures@2.2.48:
resolution: {integrity: sha512-f+Gy33Oa5Z14XY9679Zze+7VFhbsQfBFXodnU2x589l4kxGM9L5Y8zETTmcMR5pWOPQyRv4Z0lNax6xCO0NSlA==}
engines: {node: '>=18'}
which@1.3.1:
resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==}
hasBin: true
@ -16831,6 +16866,8 @@ snapshots:
electron-log@5.2.0: {}
electron-log@5.2.2: {}
electron-packager-languages@0.5.0:
dependencies:
rimraf: 2.7.1
@ -16925,6 +16962,23 @@ snapshots:
minimalistic-assert: 1.0.1
minimalistic-crypto-utils: 1.0.1
embla-carousel-react@8.3.1(react@18.3.1):
dependencies:
embla-carousel: 8.3.1
embla-carousel-reactive-utils: 8.3.1(embla-carousel@8.3.1)
react: 18.3.1
embla-carousel-reactive-utils@8.3.1(embla-carousel@8.3.1):
dependencies:
embla-carousel: 8.3.1
embla-carousel-wheel-gestures@8.0.1(embla-carousel@8.3.1):
dependencies:
embla-carousel: 8.3.1
wheel-gestures: 2.2.48
embla-carousel@8.3.1: {}
emoji-regex@10.4.0: {}
emoji-regex@8.0.0: {}
@ -21424,8 +21478,6 @@ snapshots:
csso: 5.0.5
picocolors: 1.1.0
swiper@11.1.14: {}
synckit@0.6.2:
dependencies:
tslib: 2.7.0
@ -22163,6 +22215,8 @@ snapshots:
tr46: 1.0.1
webidl-conversions: 4.0.2
wheel-gestures@2.2.48: {}
which@1.3.1:
dependencies:
isexe: 2.0.0

View File

@ -65,6 +65,12 @@ export default ({ mode }) => {
ignored: ["**/dist/**", "**/out/**", "**/public/**", ".git/**"],
},
},
resolve: {
alias: {
...viteRenderBaseConfig.resolve?.alias,
"@follow/logger": resolve(__dirname, "./packages/logger/web.ts"),
},
},
plugins: [
...((viteRenderBaseConfig.plugins ?? []) as any),
mode !== "development" &&
@ -143,8 +149,6 @@ export default ({ mode }) => {
["shiki", "@shikijs/transformers"],
["@sentry/react", "@openpanel/web"],
["zod", "react-hook-form", "@hookform/resolvers"],
["swiper"],
]),
createPlatformSpecificImportPlugin(false),