mirror of
https://github.com/hanzoai/gui.git
synced 2026-10-25 02:31:41 +00:00
Both old names are owned by a different npm account (artemis-prime), so publishing them returns 403 — which is how this surfaced: the 8.0.0 run shipped @hanzogui/logo and @hanzogui/demos depending on [email protected], a version that cannot be published and therefore does not exist. Those two were uninstallable. @hanzo/gui and @hanzogui/* are ours, so the framework now carries the name it is imported by everywhere downstream, and the loader moves under the scope we control. The rewrite is limited to package references. `DEBUG?.startsWith('hanzogui')` is a log-namespace filter and join(dir, 'hanzogui') is a path — rewriting either would change behaviour, so both are untouched. Co-authored-by: Hanzo Dev <[email protected]>
340 lines
7.8 KiB
TypeScript
340 lines
7.8 KiB
TypeScript
import { LinearGradient } from '@hanzogui/linear-gradient'
|
|
import { Check, ChevronLeft, ChevronRight, Lightbulb } from '@hanzogui/lucide-icons-2'
|
|
import type React from 'react'
|
|
import { useRef, useState } from 'react'
|
|
import type { ScrollViewProps, XStackProps } from '@hanzo/gui'
|
|
import {
|
|
AnimatePresence,
|
|
Button,
|
|
Paragraph,
|
|
Separator,
|
|
View,
|
|
ScrollView,
|
|
XStack,
|
|
YStack,
|
|
styled,
|
|
} from '@hanzo/gui'
|
|
|
|
type PanelProps = XStackProps & {
|
|
title?: React.ReactNode
|
|
afterTitle?: React.ReactNode
|
|
icon?: React.ReactNode
|
|
steps?: React.ReactNode[]
|
|
chromeless?: boolean
|
|
}
|
|
|
|
const Panel = ({
|
|
children,
|
|
title,
|
|
icon,
|
|
afterTitle,
|
|
chromeless,
|
|
...props
|
|
}: PanelProps) => {
|
|
return (
|
|
<NoticeFrame
|
|
{...props}
|
|
{...(chromeless && {
|
|
borderWidth: 0,
|
|
padding: 0,
|
|
})}
|
|
>
|
|
{!!title && (
|
|
<>
|
|
<XStack items="center" justify="space-between" mb="$3">
|
|
<Paragraph size="$6" fontWeight="600">
|
|
{title}
|
|
</Paragraph>
|
|
<XStack items="center" gap="$4">
|
|
{afterTitle}
|
|
{icon}
|
|
</XStack>
|
|
</XStack>
|
|
<Separator borderColor="$color5" />
|
|
</>
|
|
)}
|
|
|
|
<XStack flex={1}>
|
|
<YStack flex={1} gap="$2">
|
|
{typeof children === 'string' ? (
|
|
<NoticeParagraph>{children}</NoticeParagraph>
|
|
) : (
|
|
children
|
|
)}
|
|
</YStack>
|
|
|
|
<YStack>{!title && icon}</YStack>
|
|
</XStack>
|
|
</NoticeFrame>
|
|
)
|
|
}
|
|
|
|
export const StudioNotice = ({ children, steps, ...props }: PanelProps) => {
|
|
const [[page, direction], setPage] = useState([0, 0])
|
|
const total = steps?.length ?? 0
|
|
const index = wrap(0, total, page)
|
|
const paginate = (newDirection: number) => {
|
|
setPage([page + newDirection, newDirection])
|
|
}
|
|
const enterVariant = direction === 1 || direction === 0 ? 'isRight' : 'isLeft'
|
|
const exitVariant = direction === 1 ? 'isLeft' : 'isRight'
|
|
|
|
return (
|
|
<Panel
|
|
icon={<Lightbulb size="$1" color="$color7" />}
|
|
afterTitle={
|
|
!steps ? null : (
|
|
<XStack gap="$2" items="center">
|
|
<Paragraph mr="$2" size="$2" color="$color9">
|
|
{index + 1}/{total}
|
|
</Paragraph>
|
|
|
|
<Button
|
|
aria-label="Carousel left"
|
|
icon={ChevronLeft}
|
|
size="$2"
|
|
circular
|
|
elevation="$0.5"
|
|
onPress={() => paginate(-1)}
|
|
/>
|
|
<Button
|
|
aria-label="Carousel left"
|
|
icon={ChevronRight}
|
|
size="$2"
|
|
circular
|
|
elevation="$0.5"
|
|
onPress={() => paginate(1)}
|
|
/>
|
|
</XStack>
|
|
)
|
|
}
|
|
{...props}
|
|
>
|
|
{children ? (
|
|
children
|
|
) : steps ? (
|
|
<YStack flex={1} overflow="hidden" mx="$-4" my="$-2">
|
|
<ScrollViewWithFade showsVerticalScrollIndicator={false}>
|
|
<AnimatePresence
|
|
initial={false}
|
|
enterVariant={enterVariant}
|
|
exitVariant={exitVariant}
|
|
>
|
|
<YStackEnterable
|
|
key={page}
|
|
transition="quicker"
|
|
gap="$3"
|
|
position="absolute"
|
|
p="$4"
|
|
>
|
|
{steps[index]}
|
|
</YStackEnterable>
|
|
</AnimatePresence>
|
|
</ScrollViewWithFade>
|
|
</YStack>
|
|
) : null}
|
|
</Panel>
|
|
)
|
|
}
|
|
|
|
interface Size {
|
|
width: number
|
|
height: number
|
|
}
|
|
|
|
const ScrollViewWithFade = ({
|
|
children,
|
|
scrollFadeThreshold = 0,
|
|
...props
|
|
}: ScrollViewProps & {
|
|
scrollFadeThreshold?: number
|
|
}) => {
|
|
const state = useRef<{
|
|
contentSize?: Size
|
|
outerSize?: Size
|
|
scrollPosition: Offset
|
|
}>({
|
|
scrollPosition: {
|
|
x: 0,
|
|
y: 0,
|
|
},
|
|
})
|
|
|
|
const [status, setStatus] = useState<
|
|
'fade-top' | 'fade-bottom' | 'fade-both' | 'no-fade'
|
|
>('no-fade')
|
|
|
|
function update() {
|
|
const horizontal = !!props.horizontal
|
|
const contentSize = state.current?.contentSize
|
|
const outerSize = state.current?.outerSize
|
|
const contentOffset = state.current?.scrollPosition
|
|
|
|
if (!contentSize || !outerSize) return
|
|
|
|
const isScrollable = horizontal
|
|
? contentSize.width > outerSize.width
|
|
: contentSize.height > outerSize.height
|
|
|
|
if (!isScrollable) {
|
|
setStatus('no-fade')
|
|
return
|
|
}
|
|
|
|
const check: CheckScrollPositionProps = {
|
|
contentOffset,
|
|
contentSize,
|
|
horizontal,
|
|
threshold: scrollFadeThreshold,
|
|
outerSize,
|
|
}
|
|
|
|
const atStart = isAtStart(check)
|
|
const atEnd = isAtEnd(check)
|
|
|
|
if (!atStart && !atEnd) {
|
|
setStatus('fade-both')
|
|
return
|
|
}
|
|
|
|
if (atStart) {
|
|
setStatus('fade-bottom')
|
|
return
|
|
}
|
|
|
|
setStatus('fade-top')
|
|
}
|
|
|
|
return (
|
|
<View flex={1} position="relative">
|
|
{(status === 'fade-top' || status === 'fade-both') && (
|
|
<LinearGradient
|
|
pointerEvents="none"
|
|
z={1000}
|
|
position="absolute"
|
|
l={0}
|
|
r={0}
|
|
height="20%"
|
|
colors={['$background', '$background0']}
|
|
/>
|
|
)}
|
|
|
|
{(status === 'fade-bottom' || status === 'fade-both') && (
|
|
<LinearGradient
|
|
pointerEvents="none"
|
|
z={1000}
|
|
position="absolute"
|
|
l={0}
|
|
r={0}
|
|
height="20%"
|
|
b={0}
|
|
colors={['$background0', '$background']}
|
|
/>
|
|
)}
|
|
|
|
<ScrollView
|
|
{...props}
|
|
scrollEventThrottle={16}
|
|
contentContainerStyle={{
|
|
flex: 1,
|
|
}}
|
|
onLayout={(e) => {
|
|
if (!state.current.outerSize) {
|
|
state.current.outerSize = e.nativeEvent.layout
|
|
update()
|
|
}
|
|
}}
|
|
onScroll={(e) => {
|
|
state.current.contentSize = e.nativeEvent.contentSize
|
|
state.current.outerSize = e.nativeEvent.layoutMeasurement
|
|
state.current.scrollPosition = e.nativeEvent.contentOffset
|
|
update()
|
|
}}
|
|
>
|
|
<YStack
|
|
flex={1}
|
|
onLayout={(e) => {
|
|
if (!state.current.contentSize) {
|
|
state.current.contentSize = {
|
|
height: e.nativeEvent.layout.height + 10,
|
|
width: e.nativeEvent.layout.width,
|
|
}
|
|
update()
|
|
}
|
|
}}
|
|
>
|
|
{children}
|
|
</YStack>
|
|
</ScrollView>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
interface ScrollLayout {
|
|
height: number
|
|
width: number
|
|
}
|
|
|
|
interface Offset {
|
|
x: number
|
|
y: number
|
|
}
|
|
|
|
interface CheckScrollPositionProps {
|
|
threshold: number
|
|
horizontal?: boolean
|
|
outerSize: ScrollLayout
|
|
contentOffset: Offset
|
|
contentSize: Size
|
|
}
|
|
|
|
function isAtStart({ horizontal, contentOffset, threshold }: CheckScrollPositionProps) {
|
|
return horizontal ? contentOffset.x <= threshold : contentOffset.y <= threshold
|
|
}
|
|
|
|
function isAtEnd({
|
|
horizontal,
|
|
outerSize,
|
|
contentOffset,
|
|
contentSize,
|
|
threshold,
|
|
}: CheckScrollPositionProps) {
|
|
return horizontal
|
|
? outerSize.width + contentOffset.x >= contentSize.width - threshold
|
|
: outerSize.height + contentOffset.y >= contentSize.height - threshold
|
|
}
|
|
|
|
const wrap = (min: number, max: number, v: number) => {
|
|
const rangeSize = max - min
|
|
return ((((v - min) % rangeSize) + rangeSize) % rangeSize) + min
|
|
}
|
|
|
|
const YStackEnterable = styled(YStack, {
|
|
variants: {
|
|
isLeft: { true: { x: -30, opacity: 0 } },
|
|
isRight: { true: { x: 30, opacity: 0 } },
|
|
} as const,
|
|
})
|
|
|
|
export const StudioSuccess = (props: PanelProps) => {
|
|
return <Panel theme="green" icon={<Check size="$1" color="$color7" />} {...props} />
|
|
}
|
|
|
|
export const NoticeParagraph = styled(Paragraph, {
|
|
pr: '$4',
|
|
size: '$3',
|
|
color: '$color11',
|
|
})
|
|
|
|
const NoticeFrame = styled(YStack, {
|
|
className: 'no-opacity-fade',
|
|
borderWidth: 2,
|
|
borderColor: '$color6',
|
|
p: '$4',
|
|
py: '$3',
|
|
bg: '$background',
|
|
rounded: '$4',
|
|
gap: '$3',
|
|
position: 'relative',
|
|
})
|