update lightbox designs
This commit is contained in:
@@ -127,3 +127,6 @@ bskyweb/static/media/*.webp
|
|||||||
bskyweb/static/media/*.jpg
|
bskyweb/static/media/*.jpg
|
||||||
bskyweb/static/media/*.png
|
bskyweb/static/media/*.png
|
||||||
bskyweb/static/media/*.svg
|
bskyweb/static/media/*.svg
|
||||||
|
|
||||||
|
# superpowers plugin plans/specs — local-only workspace
|
||||||
|
docs/superpowers/
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,187 +0,0 @@
|
|||||||
# Lightbox Refresh — Design Spec
|
|
||||||
|
|
||||||
**Ticket:** APP-2046
|
|
||||||
**Figma:** https://www.figma.com/design/TqFguaWhkvAPnCth5u7cvE/Everything?node-id=7221-2117
|
|
||||||
**Branch:** `app-2046`
|
|
||||||
**PR strategy:** one PR — migration + reskin together
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Refresh lightbox chrome and co-locate all lightbox code under `/features/lightbox/`.
|
|
||||||
Native gets a new visual treatment (translucent circular buttons, dots pagination,
|
|
||||||
menu-based Share/Save). Web gets a minimal reskin of buttons and menu, no layout
|
|
||||||
changes. All gestures, animations, pager mechanics, and the underlying image
|
|
||||||
viewer are preserved.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
### In scope
|
|
||||||
- Move every lightbox file into `/features/lightbox/` with a light reorg.
|
|
||||||
- Rebuild native chrome (header, footer, pager dots, image menu).
|
|
||||||
- Reskin web chrome buttons to match native's translucent treatment.
|
|
||||||
- Add `Share image` menu item on web (currently only `Download image`).
|
|
||||||
- Restyle alt-text strip on both platforms to match.
|
|
||||||
|
|
||||||
### Out of scope
|
|
||||||
- Reaction bar (labeled "Inactive" in Figma).
|
|
||||||
- Web layout changes — keep chevron navigation, keep alt-text expand behavior,
|
|
||||||
keep keyboard shortcuts.
|
|
||||||
- Gesture code (pinch/pan/zoom/swipe-to-dismiss) — file moves only.
|
|
||||||
- Open/close spring animation and backdrop fade — file moves only.
|
|
||||||
- Alt-text interaction model — kept, just restyled.
|
|
||||||
|
|
||||||
## Target file structure
|
|
||||||
|
|
||||||
```
|
|
||||||
/src/features/lightbox/
|
|
||||||
├── index.tsx # re-exports Lightbox (platform split)
|
|
||||||
├── Lightbox.tsx # native shell (from view/com/lightbox/Lightbox.tsx)
|
|
||||||
├── Lightbox.web.tsx # web impl (from view/com/lightbox/Lightbox.web.tsx)
|
|
||||||
├── state.tsx # from state/lightbox.tsx
|
|
||||||
├── types.ts # from ImageViewing/@types/index.ts
|
|
||||||
├── chrome/
|
|
||||||
│ ├── CircleChromeButton.tsx # NEW — shared primitive for ••• and ✕
|
|
||||||
│ ├── Header.tsx # NEW — replaces ImageDefaultHeader
|
|
||||||
│ ├── Footer.tsx # NEW — replaces LightboxFooter
|
|
||||||
│ ├── PagerDots.tsx # NEW — dots indicator (native only)
|
|
||||||
│ └── ImageMenu.tsx # NEW — ContextMenu wrapper
|
|
||||||
└── pager/
|
|
||||||
├── ImagePager.tsx # from ImageViewing/index.tsx
|
|
||||||
├── transforms.ts # from ImageViewing/transforms.ts
|
|
||||||
└── ImageItem/
|
|
||||||
├── ImageItem.tsx
|
|
||||||
├── ImageItem.ios.tsx
|
|
||||||
└── ImageItem.android.tsx
|
|
||||||
```
|
|
||||||
|
|
||||||
### Source-to-destination map
|
|
||||||
|
|
||||||
| From | To |
|
|
||||||
|---|---|
|
|
||||||
| `src/state/lightbox.tsx` | `src/features/lightbox/state.tsx` |
|
|
||||||
| `src/view/com/lightbox/Lightbox.tsx` | `src/features/lightbox/Lightbox.tsx` |
|
|
||||||
| `src/view/com/lightbox/Lightbox.web.tsx` | `src/features/lightbox/Lightbox.web.tsx` |
|
|
||||||
| `src/view/com/lightbox/ImageViewing/index.tsx` | `src/features/lightbox/pager/ImagePager.tsx` |
|
|
||||||
| `src/view/com/lightbox/ImageViewing/transforms.ts` | `src/features/lightbox/pager/transforms.ts` |
|
|
||||||
| `src/view/com/lightbox/ImageViewing/@types/index.ts` | `src/features/lightbox/types.ts` |
|
|
||||||
| `src/view/com/lightbox/ImageViewing/components/ImageItem/*` | `src/features/lightbox/pager/ImageItem/*` |
|
|
||||||
| `src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx` | *deleted — replaced by `chrome/Header.tsx`* |
|
|
||||||
|
|
||||||
### Consumer import updates
|
|
||||||
|
|
||||||
All the imports that must move from `#/state/lightbox` → `#/features/lightbox/state` or
|
|
||||||
`#/view/com/lightbox/Lightbox` → `#/features/lightbox`:
|
|
||||||
|
|
||||||
**State (`LightboxStateProvider`, `useLightbox`, `useLightboxControls`, type `Lightbox`):**
|
|
||||||
|
|
||||||
- `src/App.native.tsx`
|
|
||||||
- `src/App.web.tsx`
|
|
||||||
- `src/state/util.ts`
|
|
||||||
- `src/screens/Profile/Header/Shell.tsx`
|
|
||||||
- `src/components/Post/Embed/ImageEmbed.tsx`
|
|
||||||
- `src/view/com/profile/ProfileSubpageHeader.tsx`
|
|
||||||
- `src/view/com/util/List.tsx`
|
|
||||||
|
|
||||||
**Lightbox component itself:**
|
|
||||||
|
|
||||||
- `src/view/shell/index.tsx`
|
|
||||||
- `src/view/shell/index.web.tsx`
|
|
||||||
|
|
||||||
Internal imports within the lightbox files themselves will also update (e.g.,
|
|
||||||
`ImagePager.tsx` importing the type from sibling `state.tsx`).
|
|
||||||
|
|
||||||
## Native chrome design
|
|
||||||
|
|
||||||
### `chrome/CircleChromeButton.tsx`
|
|
||||||
- Shared primitive used by `•••` and `✕` triggers, and reused on web.
|
|
||||||
- 36 × 36 pressable circle.
|
|
||||||
- Background: `rgba(0, 0, 0, 0.45)` — fixed, not theme-driven, because it sits
|
|
||||||
over arbitrary images.
|
|
||||||
- Icon: white, 20px.
|
|
||||||
- `hitSlop: 10`.
|
|
||||||
|
|
||||||
### `chrome/Header.tsx`
|
|
||||||
- Absolutely-positioned row, `top: insets.top`, `px_md`, `py_sm`.
|
|
||||||
- Left slot: `ImageMenu` trigger (`•••`).
|
|
||||||
- Right slot: `CircleChromeButton` with `XIcon`, closes lightbox.
|
|
||||||
- Show/hide driven by the existing `showControls` shared value in `ImagePager.tsx`
|
|
||||||
(opacity + translateY). No new gesture logic.
|
|
||||||
|
|
||||||
### `chrome/ImageMenu.tsx`
|
|
||||||
- Wraps `ContextMenu.Outer` + `ContextMenu.Trigger`.
|
|
||||||
- Trigger child renders `CircleChromeButton` with `DotsHorizontal` icon.
|
|
||||||
- `onTap` handler calls `control.open('full')` so single tap opens — no press-and-hold.
|
|
||||||
- Two items:
|
|
||||||
- `Share image` → `ShareIcon`, calls existing share util.
|
|
||||||
- `Save image` → `Download`, calls existing save util.
|
|
||||||
- Menu card surface is theme-aware (white in light mode, dark in dark mode) —
|
|
||||||
inherited from `ContextMenu`'s defaults.
|
|
||||||
|
|
||||||
### `chrome/Footer.tsx`
|
|
||||||
- Absolutely-positioned, `bottom: insets.bottom`, `px_md`, `pb_sm`.
|
|
||||||
- Renders `AltTextStrip` (if current image has alt text) above `PagerDots`.
|
|
||||||
- `AltTextStrip`:
|
|
||||||
- Translucent dark surface: `rgba(0, 0, 0, 0.45)`.
|
|
||||||
- White text, `text_sm`.
|
|
||||||
- Tap expands/collapses (port the expand/collapse state logic from
|
|
||||||
`Lightbox.web.tsx` lines 227-245).
|
|
||||||
|
|
||||||
### `chrome/PagerDots.tsx`
|
|
||||||
- Renders only when `images.length > 1`.
|
|
||||||
- Bottom-center row.
|
|
||||||
- Active dot: `6 × 6`, `bg: white`.
|
|
||||||
- Inactive dot: `6 × 6`, `bg: rgba(255,255,255,0.4)`.
|
|
||||||
- Gap: `4px`.
|
|
||||||
- Active index driven by the existing `imageIndex` shared value the pager exposes.
|
|
||||||
|
|
||||||
## Web chrome design
|
|
||||||
|
|
||||||
Changes in `Lightbox.web.tsx` are visual only:
|
|
||||||
|
|
||||||
- Replace the existing menu trigger button (lines 251-296) and close button
|
|
||||||
(lines 297-312) with `CircleChromeButton` from the native chrome folder.
|
|
||||||
(It's a plain styled Pressable — platform-agnostic.)
|
|
||||||
- Menu items:
|
|
||||||
- Existing `Download image` stays.
|
|
||||||
- NEW `Share image` — uses Web Share API when available, falls back to
|
|
||||||
copy-link-to-clipboard.
|
|
||||||
- Restyle alt-text strip to match native's translucent treatment. Keep
|
|
||||||
expand/collapse behavior.
|
|
||||||
|
|
||||||
### Web out of scope (confirmed)
|
|
||||||
- Left/right chevron navigation (lines 184-225).
|
|
||||||
- Screen-reader "Image N of M" announcement (line 246-250).
|
|
||||||
- Keyboard shortcut registration / hotkey scope management.
|
|
||||||
- No dots indicator on web (chevrons + "Image N of M" already serve this role).
|
|
||||||
|
|
||||||
## Preserved behaviors
|
|
||||||
|
|
||||||
Both platforms keep:
|
|
||||||
|
|
||||||
- Tap-to-toggle chrome visibility.
|
|
||||||
- Swipe-down-to-dismiss (native).
|
|
||||||
- Pinch/pan/double-tap zoom.
|
|
||||||
- Horizontal pager between images.
|
|
||||||
- Open/close spring from source thumbnail.
|
|
||||||
- `expo-screen-orientation` lock on open, unlock on close.
|
|
||||||
- Hotkey scope disable while lightbox is open.
|
|
||||||
|
|
||||||
## Boundaries
|
|
||||||
|
|
||||||
- `chrome/` depends only on `#/components` and the state module. It knows
|
|
||||||
nothing about gestures or pager internals.
|
|
||||||
- `pager/` owns gestures, layout, animation. Chrome is rendered via props/slots.
|
|
||||||
- `state.tsx` exports are unchanged — pure move with updated import paths.
|
|
||||||
- `CircleChromeButton` is reused across native header and web chrome — one
|
|
||||||
visual source of truth.
|
|
||||||
|
|
||||||
## Testing & verification
|
|
||||||
|
|
||||||
- Visual inspection on iOS simulator, Android emulator, web.
|
|
||||||
- Verify open/close animation unchanged from main.
|
|
||||||
- Verify tap-to-toggle chrome still works.
|
|
||||||
- Verify share and save still work end-to-end from the new menu.
|
|
||||||
- Verify alt text still displays and expands.
|
|
||||||
- Confirm no new TypeScript errors: `yarn typecheck`.
|
|
||||||
- Confirm no new lint errors: `yarn lint`.
|
|
||||||
- Confirm Jest still passes: `yarn test`.
|
|
||||||
@@ -31,6 +31,7 @@ import * as Menu from '#/components/Menu'
|
|||||||
import * as Toast from '#/components/Toast'
|
import * as Toast from '#/components/Toast'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {CircleChromeButton} from '#/features/lightbox/chrome/CircleChromeButton'
|
import {CircleChromeButton} from '#/features/lightbox/chrome/CircleChromeButton'
|
||||||
|
import {PagerDots} from '#/features/lightbox/chrome/PagerDots'
|
||||||
import {useLightbox, useLightboxControls} from '#/features/lightbox/state'
|
import {useLightbox, useLightboxControls} from '#/features/lightbox/state'
|
||||||
import {type ImageSource} from '#/features/lightbox/types'
|
import {type ImageSource} from '#/features/lightbox/types'
|
||||||
|
|
||||||
@@ -265,6 +266,7 @@ function LightboxGallery({
|
|||||||
style={[a.absolute, styles.menuBtn, delayedFadeInAnim]}>
|
style={[a.absolute, styles.menuBtn, delayedFadeInAnim]}>
|
||||||
<CircleChromeButton
|
<CircleChromeButton
|
||||||
icon={EllipsisIcon}
|
icon={EllipsisIcon}
|
||||||
|
iconStyle={{transform: [{rotate: '90deg'}]}}
|
||||||
label={_(msg`Image options`)}
|
label={_(msg`Image options`)}
|
||||||
/>
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -330,6 +332,13 @@ function LightboxGallery({
|
|||||||
onPress={onClose}
|
onPress={onClose}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
{imgs.length > 1 && (
|
||||||
|
<View
|
||||||
|
style={[a.absolute, styles.pagerDots, delayedFadeInAnim]}
|
||||||
|
pointerEvents="none">
|
||||||
|
<PagerDots count={imgs.length} activeIndex={index} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -449,6 +458,14 @@ const styles = StyleSheet.create({
|
|||||||
top: 20,
|
top: 20,
|
||||||
right: 20,
|
right: 20,
|
||||||
},
|
},
|
||||||
|
pagerDots: {
|
||||||
|
top: 20,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 44,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
leftBtn: {
|
leftBtn: {
|
||||||
left: 20,
|
left: 20,
|
||||||
right: 'auto',
|
right: 'auto',
|
||||||
|
|||||||
@@ -1,21 +1,31 @@
|
|||||||
import {type ComponentType} from 'react'
|
import {type ComponentType} from 'react'
|
||||||
import {Pressable, type PressableProps, StyleSheet} from 'react-native'
|
import {
|
||||||
|
Pressable,
|
||||||
|
type PressableProps,
|
||||||
|
type StyleProp,
|
||||||
|
StyleSheet,
|
||||||
|
type TextStyle,
|
||||||
|
} from 'react-native'
|
||||||
|
import {BlurView} from 'expo-blur'
|
||||||
|
|
||||||
import {HITSLOP_10} from '#/lib/constants'
|
import {HITSLOP_10} from '#/lib/constants'
|
||||||
import {type Props as IconProps} from '#/components/icons/common'
|
import {type Props as IconProps} from '#/components/icons/common'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
icon: ComponentType<IconProps>
|
icon: ComponentType<IconProps>
|
||||||
|
iconStyle?: StyleProp<TextStyle>
|
||||||
label: string
|
label: string
|
||||||
onPress?: PressableProps['onPress']
|
onPress?: PressableProps['onPress']
|
||||||
testID?: string
|
testID?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const SIZE = 36
|
const SIZE = 44
|
||||||
const BG = 'rgba(0, 0, 0, 0.45)'
|
const RADIUS = 24
|
||||||
|
const ICON = 24
|
||||||
|
|
||||||
export function CircleChromeButton({
|
export function CircleChromeButton({
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
|
iconStyle,
|
||||||
label,
|
label,
|
||||||
onPress,
|
onPress,
|
||||||
testID,
|
testID,
|
||||||
@@ -29,7 +39,9 @@ export function CircleChromeButton({
|
|||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
testID={testID}
|
testID={testID}
|
||||||
style={({pressed}) => [styles.root, pressed && styles.pressed]}>
|
style={({pressed}) => [styles.root, pressed && styles.pressed]}>
|
||||||
<Icon width={20} fill="#fff" />
|
<BlurView intensity={20} tint="dark" style={styles.inner}>
|
||||||
|
<Icon width={ICON} fill="#fff" style={iconStyle} />
|
||||||
|
</BlurView>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -38,10 +50,14 @@ const styles = StyleSheet.create({
|
|||||||
root: {
|
root: {
|
||||||
width: SIZE,
|
width: SIZE,
|
||||||
height: SIZE,
|
height: SIZE,
|
||||||
borderRadius: SIZE / 2,
|
borderRadius: RADIUS,
|
||||||
backgroundColor: BG,
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
inner: {
|
||||||
|
flex: 1,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
},
|
},
|
||||||
pressed: {
|
pressed: {
|
||||||
opacity: 0.85,
|
opacity: 0.85,
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import {type ComponentType} from 'react'
|
||||||
|
import {
|
||||||
|
Pressable,
|
||||||
|
type PressableProps,
|
||||||
|
type StyleProp,
|
||||||
|
StyleSheet,
|
||||||
|
type TextStyle,
|
||||||
|
View,
|
||||||
|
} from 'react-native'
|
||||||
|
|
||||||
|
import {HITSLOP_10} from '#/lib/constants'
|
||||||
|
import {type Props as IconProps} from '#/components/icons/common'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
icon: ComponentType<IconProps>
|
||||||
|
iconStyle?: StyleProp<TextStyle>
|
||||||
|
label: string
|
||||||
|
onPress?: PressableProps['onPress']
|
||||||
|
testID?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIZE = 44
|
||||||
|
const RADIUS = 24
|
||||||
|
const ICON = 24
|
||||||
|
|
||||||
|
export function CircleChromeButton({
|
||||||
|
icon: Icon,
|
||||||
|
iconStyle,
|
||||||
|
label,
|
||||||
|
onPress,
|
||||||
|
testID,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={label}
|
||||||
|
accessibilityHint=""
|
||||||
|
hitSlop={HITSLOP_10}
|
||||||
|
onPress={onPress}
|
||||||
|
testID={testID}
|
||||||
|
style={({pressed}) => [styles.root, pressed && styles.pressed]}>
|
||||||
|
<View style={styles.inner}>
|
||||||
|
<Icon width={ICON} fill="#fff" style={iconStyle} />
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: {
|
||||||
|
width: SIZE,
|
||||||
|
height: SIZE,
|
||||||
|
borderRadius: RADIUS,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
inner: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||||
|
// @ts-expect-error web-only
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
WebkitBackdropFilter: 'blur(8px)',
|
||||||
|
},
|
||||||
|
pressed: {
|
||||||
|
opacity: 0.85,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -12,66 +12,56 @@ import {useLingui} from '@lingui/react'
|
|||||||
|
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {PagerDots} from './PagerDots'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
altText: string | undefined
|
altText: string | undefined
|
||||||
isAltExpanded: boolean
|
isAltExpanded: boolean
|
||||||
onToggleAltExpanded: () => void
|
onToggleAltExpanded: () => void
|
||||||
imageCount: number
|
|
||||||
activeIndex: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Footer({
|
export function Footer({altText, isAltExpanded, onToggleAltExpanded}: Props) {
|
||||||
altText,
|
|
||||||
isAltExpanded,
|
|
||||||
onToggleAltExpanded,
|
|
||||||
imageCount,
|
|
||||||
activeIndex,
|
|
||||||
}: Props) {
|
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
const isMomentumScrolling = useRef(false)
|
const isMomentumScrolling = useRef(false)
|
||||||
|
|
||||||
|
if (!altText) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[styles.root, {paddingBottom: insets.bottom + 8}]}
|
style={[styles.root, {paddingBottom: insets.bottom + 8}]}
|
||||||
pointerEvents="box-none">
|
pointerEvents="box-none">
|
||||||
{altText ? (
|
<View style={[a.mx_md, styles.altWrap]}>
|
||||||
<View style={[a.mx_md, a.mb_sm, styles.altWrap]}>
|
<ScrollView
|
||||||
<ScrollView
|
scrollEnabled={isAltExpanded}
|
||||||
scrollEnabled={isAltExpanded}
|
onMomentumScrollBegin={() => {
|
||||||
onMomentumScrollBegin={() => {
|
isMomentumScrolling.current = true
|
||||||
isMomentumScrolling.current = true
|
}}
|
||||||
}}
|
onMomentumScrollEnd={() => {
|
||||||
onMomentumScrollEnd={() => {
|
isMomentumScrolling.current = false
|
||||||
isMomentumScrolling.current = false
|
}}
|
||||||
}}
|
contentContainerStyle={[a.px_md, a.py_sm]}>
|
||||||
contentContainerStyle={[a.px_md, a.py_sm]}>
|
<Pressable
|
||||||
<Pressable
|
accessibilityRole="button"
|
||||||
accessibilityRole="button"
|
accessibilityLabel={_(msg`Expand alt text`)}
|
||||||
accessibilityLabel={_(msg`Expand alt text`)}
|
accessibilityHint=""
|
||||||
accessibilityHint=""
|
onPress={() => {
|
||||||
onPress={() => {
|
if (isMomentumScrolling.current) return
|
||||||
if (isMomentumScrolling.current) return
|
LayoutAnimation.configureNext({
|
||||||
LayoutAnimation.configureNext({
|
duration: 450,
|
||||||
duration: 450,
|
update: {type: 'spring', springDamping: 1},
|
||||||
update: {type: 'spring', springDamping: 1},
|
})
|
||||||
})
|
onToggleAltExpanded()
|
||||||
onToggleAltExpanded()
|
}}>
|
||||||
}}>
|
<Text
|
||||||
<Text
|
emoji
|
||||||
emoji
|
selectable
|
||||||
selectable
|
style={[a.text_sm, styles.altText]}
|
||||||
style={[a.text_sm, styles.altText]}
|
numberOfLines={isAltExpanded ? undefined : 3}>
|
||||||
numberOfLines={isAltExpanded ? undefined : 3}>
|
{altText}
|
||||||
{altText}
|
</Text>
|
||||||
</Text>
|
</Pressable>
|
||||||
</Pressable>
|
</ScrollView>
|
||||||
</ScrollView>
|
</View>
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
<PagerDots count={imageCount} activeIndex={activeIndex} />
|
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,23 @@ import {atoms as a} from '#/alf'
|
|||||||
import {TimesLarge_Stroke2_Corner0_Rounded as CloseIcon} from '#/components/icons/Times'
|
import {TimesLarge_Stroke2_Corner0_Rounded as CloseIcon} from '#/components/icons/Times'
|
||||||
import {CircleChromeButton} from './CircleChromeButton'
|
import {CircleChromeButton} from './CircleChromeButton'
|
||||||
import {ImageMenu} from './ImageMenu'
|
import {ImageMenu} from './ImageMenu'
|
||||||
|
import {PagerDots} from './PagerDots'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onRequestClose: () => void
|
onRequestClose: () => void
|
||||||
onPressShare: () => void
|
onPressShare: () => void
|
||||||
onPressSave: () => void
|
onPressSave: () => void
|
||||||
|
imageCount: number
|
||||||
|
activeIndex: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Header({onRequestClose, onPressShare, onPressSave}: Props) {
|
export function Header({
|
||||||
|
onRequestClose,
|
||||||
|
onPressShare,
|
||||||
|
onPressSave,
|
||||||
|
imageCount,
|
||||||
|
activeIndex,
|
||||||
|
}: Props) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
|
|
||||||
@@ -30,6 +39,7 @@ export function Header({onRequestClose, onPressShare, onPressSave}: Props) {
|
|||||||
]}
|
]}
|
||||||
pointerEvents="box-none">
|
pointerEvents="box-none">
|
||||||
<ImageMenu onPressShare={onPressShare} onPressSave={onPressSave} />
|
<ImageMenu onPressShare={onPressShare} onPressSave={onPressSave} />
|
||||||
|
<PagerDots count={imageCount} activeIndex={activeIndex} />
|
||||||
<CircleChromeButton
|
<CircleChromeButton
|
||||||
icon={CloseIcon}
|
icon={CloseIcon}
|
||||||
label={_(msg`Close image`)}
|
label={_(msg`Close image`)}
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
|
import {useRef, useState} from 'react'
|
||||||
|
import {Modal, Pressable, StyleSheet, View} from 'react-native'
|
||||||
|
import Animated, {
|
||||||
|
interpolate,
|
||||||
|
runOnJS,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withSpring,
|
||||||
|
withTiming,
|
||||||
|
} from 'react-native-reanimated'
|
||||||
import {msg} from '@lingui/core/macro'
|
import {msg} from '@lingui/core/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import * as ContextMenu from '#/components/ContextMenu'
|
import {atoms as a} from '#/alf'
|
||||||
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ShareIcon} from '#/components/icons/ArrowOutOfBox'
|
import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight'
|
||||||
|
import {type Props as IconProps} from '#/components/icons/common'
|
||||||
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsIcon} from '#/components/icons/DotGrid'
|
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsIcon} from '#/components/icons/DotGrid'
|
||||||
import {Download_Stroke2_Corner0_Rounded as DownloadIcon} from '#/components/icons/Download'
|
import {Download_Stroke2_Corner0_Rounded as DownloadIcon} from '#/components/icons/Download'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
import {CircleChromeButton} from './CircleChromeButton'
|
import {CircleChromeButton} from './CircleChromeButton'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -12,39 +24,172 @@ type Props = {
|
|||||||
onPressSave: () => void
|
onPressSave: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Anchor = {x: number; y: number; width: number; height: number}
|
||||||
|
|
||||||
|
const MENU_WIDTH = 160
|
||||||
|
const GAP = 6
|
||||||
|
const CARD_BG = '#000000'
|
||||||
|
const CARD_BORDER = '#232e3e'
|
||||||
|
const ITEM_TEXT = '#f9fafb'
|
||||||
|
|
||||||
|
const SPRING_IN = {damping: 18, mass: 0.6, stiffness: 240}
|
||||||
|
const TIMING_OUT = {duration: 150}
|
||||||
|
|
||||||
export function ImageMenu({onPressShare, onPressSave}: Props) {
|
export function ImageMenu({onPressShare, onPressSave}: Props) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
const triggerRef = useRef<View>(null)
|
||||||
|
const [isMounted, setIsMounted] = useState(false)
|
||||||
|
const [anchor, setAnchor] = useState<Anchor | null>(null)
|
||||||
|
const progress = useSharedValue(0)
|
||||||
|
|
||||||
|
const open = () => {
|
||||||
|
triggerRef.current?.measureInWindow((x, y, width, height) => {
|
||||||
|
setAnchor({x, y, width, height})
|
||||||
|
setIsMounted(true)
|
||||||
|
progress.set(withSpring(1, SPRING_IN))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
progress.set(
|
||||||
|
withTiming(0, TIMING_OUT, finished => {
|
||||||
|
if (finished) {
|
||||||
|
runOnJS(setIsMounted)(false)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const runAction = (action: () => void) => {
|
||||||
|
close()
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu.Root>
|
<>
|
||||||
<ContextMenu.Trigger
|
<View ref={triggerRef} collapsable={false}>
|
||||||
label={_(msg`Image options`)}
|
<CircleChromeButton
|
||||||
contentLabel={_(msg`Image options`)}>
|
icon={DotsIcon}
|
||||||
{triggerProps => {
|
iconStyle={{transform: [{rotate: '90deg'}]}}
|
||||||
if (!triggerProps.IS_NATIVE) {
|
label={_(msg`Image options`)}
|
||||||
// ImageMenu is native-only; web uses Lightbox.web.tsx's own menu
|
onPress={open}
|
||||||
return null
|
/>
|
||||||
}
|
</View>
|
||||||
return (
|
<Modal
|
||||||
<CircleChromeButton
|
transparent
|
||||||
icon={DotsIcon}
|
visible={isMounted}
|
||||||
label={_(msg`Image options`)}
|
animationType="none"
|
||||||
onPress={() => triggerProps.control.open('full')}
|
onRequestClose={close}
|
||||||
|
statusBarTranslucent>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={_(msg`Close menu`)}
|
||||||
|
accessibilityHint=""
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
onPress={close}
|
||||||
|
/>
|
||||||
|
{anchor && (
|
||||||
|
<MenuCard anchor={anchor} progress={progress}>
|
||||||
|
<MenuItem
|
||||||
|
icon={ShareIcon}
|
||||||
|
label={_(msg`Share image`)}
|
||||||
|
onPress={() => runAction(onPressShare)}
|
||||||
/>
|
/>
|
||||||
)
|
<MenuItem
|
||||||
}}
|
icon={DownloadIcon}
|
||||||
</ContextMenu.Trigger>
|
label={_(msg`Save image`)}
|
||||||
|
onPress={() => runAction(onPressSave)}
|
||||||
<ContextMenu.Outer align="left">
|
/>
|
||||||
<ContextMenu.Item label={_(msg`Share image`)} onPress={onPressShare}>
|
</MenuCard>
|
||||||
<ContextMenu.ItemIcon icon={ShareIcon} />
|
)}
|
||||||
<ContextMenu.ItemText>{_(msg`Share image`)}</ContextMenu.ItemText>
|
</Modal>
|
||||||
</ContextMenu.Item>
|
</>
|
||||||
<ContextMenu.Item label={_(msg`Save image`)} onPress={onPressSave}>
|
|
||||||
<ContextMenu.ItemIcon icon={DownloadIcon} />
|
|
||||||
<ContextMenu.ItemText>{_(msg`Save image`)}</ContextMenu.ItemText>
|
|
||||||
</ContextMenu.Item>
|
|
||||||
</ContextMenu.Outer>
|
|
||||||
</ContextMenu.Root>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MenuCard({
|
||||||
|
anchor,
|
||||||
|
progress,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
anchor: Anchor
|
||||||
|
progress: ReturnType<typeof useSharedValue<number>>
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: progress.get(),
|
||||||
|
transform: [{scale: interpolate(progress.get(), [0, 1], [0.9, 1])}],
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
styles.card,
|
||||||
|
{
|
||||||
|
top: anchor.y + anchor.height + GAP,
|
||||||
|
left: anchor.x,
|
||||||
|
transformOrigin: 'top left',
|
||||||
|
},
|
||||||
|
animatedStyle,
|
||||||
|
]}>
|
||||||
|
{children}
|
||||||
|
</Animated.View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuItem({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
icon: React.ComponentType<IconProps>
|
||||||
|
label: string
|
||||||
|
onPress: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={label}
|
||||||
|
accessibilityHint=""
|
||||||
|
onPress={onPress}
|
||||||
|
style={({pressed}) => [styles.item, pressed && styles.itemPressed]}>
|
||||||
|
<Icon width={18} fill={ITEM_TEXT} />
|
||||||
|
<Text style={styles.itemText}>{label}</Text>
|
||||||
|
</Pressable>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
width: MENU_WIDTH,
|
||||||
|
padding: 8,
|
||||||
|
borderRadius: 16,
|
||||||
|
borderWidth: 0.5,
|
||||||
|
borderColor: CARD_BORDER,
|
||||||
|
backgroundColor: CARD_BG,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: {width: 0, height: 4},
|
||||||
|
shadowOpacity: 0.16,
|
||||||
|
shadowRadius: 20,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
item: {
|
||||||
|
height: 44,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
itemPressed: {
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||||
|
},
|
||||||
|
itemText: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '500',
|
||||||
|
lineHeight: 19.5,
|
||||||
|
color: ITEM_TEXT,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|||||||
@@ -7,22 +7,26 @@ type Props = {
|
|||||||
activeIndex: number
|
activeIndex: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const DOT = 6
|
const ACTIVE = 6
|
||||||
const GAP = 4
|
const INACTIVE = 4
|
||||||
|
const GAP = 5
|
||||||
|
|
||||||
export function PagerDots({count, activeIndex}: Props) {
|
export function PagerDots({count, activeIndex}: Props) {
|
||||||
if (count <= 1) return null
|
if (count <= 1) return null
|
||||||
return (
|
return (
|
||||||
<View style={[a.flex_row, a.justify_center, a.align_center, styles.row]}>
|
<View style={[a.flex_row, a.align_center, a.justify_center, styles.row]}>
|
||||||
{Array.from({length: count}).map((_, i) => (
|
{Array.from({length: count}).map((_, i) => {
|
||||||
<View
|
const isActive = i === activeIndex
|
||||||
key={i}
|
return (
|
||||||
style={[
|
<View
|
||||||
styles.dot,
|
key={i}
|
||||||
i === activeIndex ? styles.active : styles.inactive,
|
style={[
|
||||||
]}
|
isActive ? styles.active : styles.inactive,
|
||||||
/>
|
isActive ? styles.activeDot : styles.inactiveDot,
|
||||||
))}
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -31,10 +35,15 @@ const styles = StyleSheet.create({
|
|||||||
row: {
|
row: {
|
||||||
gap: GAP,
|
gap: GAP,
|
||||||
},
|
},
|
||||||
dot: {
|
activeDot: {
|
||||||
width: DOT,
|
width: ACTIVE,
|
||||||
height: DOT,
|
height: ACTIVE,
|
||||||
borderRadius: DOT / 2,
|
borderRadius: ACTIVE / 2,
|
||||||
|
},
|
||||||
|
inactiveDot: {
|
||||||
|
width: INACTIVE,
|
||||||
|
height: INACTIVE,
|
||||||
|
borderRadius: INACTIVE / 2,
|
||||||
},
|
},
|
||||||
active: {
|
active: {
|
||||||
backgroundColor: '#fff',
|
backgroundColor: '#fff',
|
||||||
|
|||||||
@@ -381,7 +381,7 @@ function ImageView({
|
|||||||
overdrag={true}
|
overdrag={true}
|
||||||
style={styles.pager}>
|
style={styles.pager}>
|
||||||
{images.map((imageSrc, i) => (
|
{images.map((imageSrc, i) => (
|
||||||
<View key={imageSrc.uri}>
|
<View key={`${i}-${imageSrc.uri}`}>
|
||||||
<LightboxImage
|
<LightboxImage
|
||||||
onTap={onTap}
|
onTap={onTap}
|
||||||
onZoom={onZoom}
|
onZoom={onZoom}
|
||||||
@@ -410,6 +410,8 @@ function ImageView({
|
|||||||
onRequestClose={handleRequestClose}
|
onRequestClose={handleRequestClose}
|
||||||
onPressShare={() => onPressShare(images[imageIndex].uri)}
|
onPressShare={() => onPressShare(images[imageIndex].uri)}
|
||||||
onPressSave={() => onPressSave(images[imageIndex].uri)}
|
onPressSave={() => onPressSave(images[imageIndex].uri)}
|
||||||
|
imageCount={images.length}
|
||||||
|
activeIndex={imageIndex}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
@@ -420,8 +422,6 @@ function ImageView({
|
|||||||
altText={images[imageIndex].alt}
|
altText={images[imageIndex].alt}
|
||||||
isAltExpanded={isAltExpanded}
|
isAltExpanded={isAltExpanded}
|
||||||
onToggleAltExpanded={() => setIsAltExpanded(e => !e)}
|
onToggleAltExpanded={() => setIsAltExpanded(e => !e)}
|
||||||
imageCount={images.length}
|
|
||||||
activeIndex={imageIndex}
|
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
Reference in New Issue
Block a user