@toneflix/flipbook-native 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +23 -2
- package/assets/textures/grain-charcoal@2x.webp +0 -0
- package/assets/textures/grain-fine@2x.webp +0 -0
- package/assets/textures/grain-kraft@2x.webp +0 -0
- package/assets/textures/paper-charcoal@2x.webp +0 -0
- package/assets/textures/paper-fine@2x.webp +0 -0
- package/assets/textures/paper-kraft@2x.webp +0 -0
- package/lib/Flipbook.d.ts +53 -0
- package/lib/Flipbook.js +280 -0
- package/lib/Gradient.d.ts +14 -0
- package/lib/Gradient.js +10 -0
- package/lib/PrintedPhoto.d.ts +15 -0
- package/lib/PrintedPhoto.js +168 -0
- package/lib/geometry.d.ts +44 -0
- package/lib/geometry.js +114 -0
- package/lib/index.d.ts +8 -0
- package/lib/index.js +8 -0
- package/lib/look.d.ts +53 -0
- package/lib/look.js +68 -0
- package/lib/orientation.d.ts +5 -0
- package/lib/orientation.js +25 -0
- package/lib/pages.d.ts +59 -0
- package/lib/pages.js +135 -0
- package/package.json +50 -3
- package/src/Flipbook.tsx +600 -0
- package/src/Gradient.tsx +39 -0
- package/src/PrintedPhoto.tsx +456 -0
- package/src/geometry.ts +147 -0
- package/src/index.ts +19 -0
- package/src/look.tsx +110 -0
- package/src/orientation.ts +32 -0
- package/src/pages.tsx +440 -0
- package/src/require.d.ts +1 -0
package/src/Flipbook.tsx
ADDED
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
import {
|
|
2
|
+
forwardRef,
|
|
3
|
+
memo,
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useImperativeHandle,
|
|
7
|
+
useLayoutEffect,
|
|
8
|
+
useMemo,
|
|
9
|
+
useReducer,
|
|
10
|
+
useRef,
|
|
11
|
+
useState,
|
|
12
|
+
type ReactNode,
|
|
13
|
+
} from 'react'
|
|
14
|
+
import {
|
|
15
|
+
AccessibilityInfo,
|
|
16
|
+
Animated,
|
|
17
|
+
PanResponder,
|
|
18
|
+
Pressable,
|
|
19
|
+
ScrollView,
|
|
20
|
+
StyleSheet,
|
|
21
|
+
Text,
|
|
22
|
+
View,
|
|
23
|
+
useWindowDimensions,
|
|
24
|
+
type LayoutChangeEvent,
|
|
25
|
+
type StyleProp,
|
|
26
|
+
type ViewStyle,
|
|
27
|
+
} from 'react-native'
|
|
28
|
+
import Svg, { Defs, Path, RadialGradient, Rect, Stop } from 'react-native-svg'
|
|
29
|
+
import {
|
|
30
|
+
BookLayout,
|
|
31
|
+
COVER_THEMES,
|
|
32
|
+
DEFAULT_LABELS,
|
|
33
|
+
FlipbookReader,
|
|
34
|
+
type Face,
|
|
35
|
+
type FlipbookBook,
|
|
36
|
+
type FlipbookLabels,
|
|
37
|
+
type GripOptions,
|
|
38
|
+
type TurnPhysics,
|
|
39
|
+
type Vec,
|
|
40
|
+
} from '@toneflix/flipbook-core'
|
|
41
|
+
import { FoldLayout, type Band, type Role } from './geometry'
|
|
42
|
+
import { Gradient } from './Gradient'
|
|
43
|
+
import { BookLookProvider, type BookFonts } from './look'
|
|
44
|
+
import { BackCoverView, BlankPageView, ColophonView, CoverView, PAGE_RATIO, PageView, TitlePageView } from './pages'
|
|
45
|
+
import { lockLandscape } from './orientation'
|
|
46
|
+
|
|
47
|
+
export interface FlipbookPlace {
|
|
48
|
+
/** How many leaves lie on the left: 0 is the closed front cover. */
|
|
49
|
+
flipped: number
|
|
50
|
+
label: string
|
|
51
|
+
faces: Face[]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface FlipbookProps {
|
|
55
|
+
book: FlipbookBook
|
|
56
|
+
/** Open at this page (1-based) instead of the cover. */
|
|
57
|
+
startPage?: number
|
|
58
|
+
/** Two pages side by side, one at a time, or whichever fits the space (default). */
|
|
59
|
+
layout?: 'auto' | 'spread' | 'single'
|
|
60
|
+
/** Show the top and bottom bars. */
|
|
61
|
+
controls?: boolean
|
|
62
|
+
/** Hide the bars after this many ms without a touch, or never with false. */
|
|
63
|
+
autoHide?: number | false
|
|
64
|
+
thumbnails?: boolean
|
|
65
|
+
/**
|
|
66
|
+
* Read sideways like a real book: locks the screen to landscape while the book is open when
|
|
67
|
+
* `expo-screen-orientation` is installed, and otherwise asks the reader to turn the phone.
|
|
68
|
+
*/
|
|
69
|
+
landscape?: boolean
|
|
70
|
+
/** Covers swing on the spine like board instead of curling like paper. */
|
|
71
|
+
stiffCovers?: boolean
|
|
72
|
+
physics?: Partial<TurnPhysics>
|
|
73
|
+
grip?: Partial<GripOptions>
|
|
74
|
+
/** Strength of the light and shadow around a turning page, from 0 to 1 or more. */
|
|
75
|
+
shading?: number
|
|
76
|
+
/** Backdrop colour behind the book. Defaults to a glow tinted by the cover. */
|
|
77
|
+
background?: string
|
|
78
|
+
fonts?: BookFonts
|
|
79
|
+
labels?: Partial<FlipbookLabels>
|
|
80
|
+
/** Extra controls at the start and end of the top bar, e.g. a close button. */
|
|
81
|
+
topLeft?: ReactNode
|
|
82
|
+
topRight?: ReactNode
|
|
83
|
+
style?: StyleProp<ViewStyle>
|
|
84
|
+
onPageChange?: (place: FlipbookPlace) => void
|
|
85
|
+
/** A page started to turn over, e.g. to play a sound. */
|
|
86
|
+
onFlip?: () => void
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface FlipbookHandle {
|
|
90
|
+
next(): void
|
|
91
|
+
prev(): void
|
|
92
|
+
go(flipped: number): void
|
|
93
|
+
goToPage(page: number): void
|
|
94
|
+
readonly reader: FlipbookReader
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** One face's content. Memoised so turning a page only moves its wrappers. */
|
|
98
|
+
const FaceContent = memo(function FaceContent({
|
|
99
|
+
face,
|
|
100
|
+
book,
|
|
101
|
+
width,
|
|
102
|
+
side,
|
|
103
|
+
}: {
|
|
104
|
+
face: Face
|
|
105
|
+
book: FlipbookBook
|
|
106
|
+
width: number
|
|
107
|
+
side: 'left' | 'right'
|
|
108
|
+
}) {
|
|
109
|
+
switch (face.kind) {
|
|
110
|
+
case 'cover':
|
|
111
|
+
return <CoverView book={book} width={width} />
|
|
112
|
+
case 'back':
|
|
113
|
+
return <BackCoverView book={book} width={width} />
|
|
114
|
+
case 'title':
|
|
115
|
+
return <TitlePageView book={book} width={width} side={side} />
|
|
116
|
+
case 'colophon':
|
|
117
|
+
return <ColophonView book={book} width={width} side={side} />
|
|
118
|
+
case 'blank':
|
|
119
|
+
return <BlankPageView width={width} side={side} />
|
|
120
|
+
case 'page':
|
|
121
|
+
return <PageView page={book.pages[face.index]} width={width} number={face.index + 1} side={side} />
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
const BAND_STOPS: Record<Band['name'], { offset: number; color: string; opacity: number }[]> = {
|
|
126
|
+
under: [
|
|
127
|
+
{ offset: 0, color: '#000', opacity: 0.42 },
|
|
128
|
+
{ offset: 0.17, color: '#000', opacity: 0.2 },
|
|
129
|
+
{ offset: 0.53, color: '#000', opacity: 0.06 },
|
|
130
|
+
{ offset: 1, color: '#000', opacity: 0 },
|
|
131
|
+
],
|
|
132
|
+
bend: [
|
|
133
|
+
{ offset: 0, color: '#000', opacity: 0.16 },
|
|
134
|
+
{ offset: 0.5, color: '#000', opacity: 0.04 },
|
|
135
|
+
{ offset: 1, color: '#000', opacity: 0 },
|
|
136
|
+
],
|
|
137
|
+
curl: [
|
|
138
|
+
{ offset: 0, color: '#000', opacity: 0.3 },
|
|
139
|
+
{ offset: 0.035, color: '#000', opacity: 0.1 },
|
|
140
|
+
{ offset: 0.11, color: '#fff', opacity: 0.2 },
|
|
141
|
+
{ offset: 0.25, color: '#fff', opacity: 0.06 },
|
|
142
|
+
{ offset: 0.6, color: '#000', opacity: 0.05 },
|
|
143
|
+
{ offset: 1, color: '#000', opacity: 0.12 },
|
|
144
|
+
],
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A photo book that opens across two pages, turned by dragging, flicking, tapping or the buttons. */
|
|
148
|
+
export const Flipbook = forwardRef<FlipbookHandle, FlipbookProps>(function Flipbook(props, ref) {
|
|
149
|
+
const {
|
|
150
|
+
book,
|
|
151
|
+
startPage,
|
|
152
|
+
layout: layoutMode = 'auto',
|
|
153
|
+
controls = true,
|
|
154
|
+
autoHide = 3200,
|
|
155
|
+
thumbnails = true,
|
|
156
|
+
landscape = true,
|
|
157
|
+
stiffCovers = true,
|
|
158
|
+
physics,
|
|
159
|
+
grip,
|
|
160
|
+
shading = 1,
|
|
161
|
+
background,
|
|
162
|
+
fonts,
|
|
163
|
+
topLeft,
|
|
164
|
+
topRight,
|
|
165
|
+
style,
|
|
166
|
+
onPageChange,
|
|
167
|
+
onFlip,
|
|
168
|
+
} = props
|
|
169
|
+
const labels = { ...DEFAULT_LABELS, ...props.labels }
|
|
170
|
+
const screen = useWindowDimensions()
|
|
171
|
+
const [stage, setStage] = useState({ width: 0, height: 0 })
|
|
172
|
+
const [portraitChoice, setPortraitChoice] = useState<'ask' | 'single'>('ask')
|
|
173
|
+
const [locked, setLocked] = useState(false)
|
|
174
|
+
const [showPages, setShowPages] = useState(false)
|
|
175
|
+
const [, rerender] = useReducer((n: number) => n + 1, 0)
|
|
176
|
+
const reducedMotion = useRef(false)
|
|
177
|
+
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
AccessibilityInfo.isReduceMotionEnabled?.().then((on) => (reducedMotion.current = on))
|
|
180
|
+
const subscription = AccessibilityInfo.addEventListener?.('reduceMotionChanged', (on) => (reducedMotion.current = on))
|
|
181
|
+
|
|
182
|
+
return () => subscription?.remove()
|
|
183
|
+
}, [])
|
|
184
|
+
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
if (!landscape) return
|
|
187
|
+
|
|
188
|
+
let release: (() => void) | undefined
|
|
189
|
+
let cancelled = false
|
|
190
|
+
|
|
191
|
+
lockLandscape().then((unlock) => {
|
|
192
|
+
if (cancelled) return unlock?.()
|
|
193
|
+
|
|
194
|
+
release = unlock ?? undefined
|
|
195
|
+
setLocked(!!unlock)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
return () => {
|
|
199
|
+
cancelled = true
|
|
200
|
+
release?.()
|
|
201
|
+
}
|
|
202
|
+
}, [landscape])
|
|
203
|
+
|
|
204
|
+
// Phones held upright are asked to turn sideways when the screen can't be locked; single pages are the fallback.
|
|
205
|
+
const portrait = screen.height > screen.width
|
|
206
|
+
const showRotatePrompt = landscape && !locked && portrait && portraitChoice === 'ask' && layoutMode !== 'single'
|
|
207
|
+
const narrow = stage.width > 0 && stage.width / stage.height < 0.95
|
|
208
|
+
const spread = layoutMode === 'auto' ? !(portrait && portraitChoice === 'single') && !narrow : layoutMode === 'spread'
|
|
209
|
+
|
|
210
|
+
const pageCount = book.pages.length
|
|
211
|
+
const layout = useMemo(() => new BookLayout(pageCount, { spread, stiffCovers }), [pageCount, spread, stiffCovers])
|
|
212
|
+
|
|
213
|
+
// Page size: as large as fits, keeping the page shape, with room for the controls.
|
|
214
|
+
const across = spread ? 2 : 1
|
|
215
|
+
const marginY = !controls || stage.height < 500 ? 16 : Math.min(150, stage.height * 0.16)
|
|
216
|
+
const pageW = Math.floor(Math.max(0, Math.min((stage.height - marginY) * PAGE_RATIO, (stage.width - (spread ? 48 : 16)) / across)))
|
|
217
|
+
const pageH = Math.floor(pageW / PAGE_RATIO)
|
|
218
|
+
|
|
219
|
+
const handlers = useRef({ onFlip })
|
|
220
|
+
handlers.current = { onFlip }
|
|
221
|
+
|
|
222
|
+
const [reader] = useState(
|
|
223
|
+
() =>
|
|
224
|
+
new FlipbookReader(
|
|
225
|
+
layout,
|
|
226
|
+
{ frame: rerender, change: rerender, flip: () => handlers.current.onFlip?.() },
|
|
227
|
+
{ flipped: layout.flippedForPage(startPage), physics, grip, reducedMotion: () => reducedMotion.current },
|
|
228
|
+
),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
useLayoutEffect(() => reader.configure({ layout, width: pageW, height: pageH }), [reader, layout, pageW, pageH])
|
|
232
|
+
|
|
233
|
+
useEffect(() => {
|
|
234
|
+
reader.engine.physics = { ...reader.engine.physics, ...physics }
|
|
235
|
+
reader.grip = { ...reader.grip, ...grip }
|
|
236
|
+
}, [reader, physics, grip])
|
|
237
|
+
|
|
238
|
+
useEffect(() => () => reader.destroy(), [reader])
|
|
239
|
+
|
|
240
|
+
const { flipped, active } = reader
|
|
241
|
+
const place = `${layout.spread}:${flipped}`
|
|
242
|
+
const onPageChangeRef = useRef(onPageChange)
|
|
243
|
+
onPageChangeRef.current = onPageChange
|
|
244
|
+
|
|
245
|
+
useEffect(() => {
|
|
246
|
+
onPageChangeRef.current?.({ flipped: reader.flipped, label: reader.label, faces: reader.layout.visible(reader.flipped) })
|
|
247
|
+
}, [place, reader])
|
|
248
|
+
|
|
249
|
+
useImperativeHandle(
|
|
250
|
+
ref,
|
|
251
|
+
() => ({
|
|
252
|
+
next: () => reader.next(),
|
|
253
|
+
prev: () => reader.prev(),
|
|
254
|
+
go: (target) => reader.go(target),
|
|
255
|
+
goToPage: (page) => reader.go(reader.layout.flippedForPage(page)),
|
|
256
|
+
reader,
|
|
257
|
+
}),
|
|
258
|
+
[reader],
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
// ------------------------------------------------- Controls that hide while reading
|
|
262
|
+
const chrome = useRef(new Animated.Value(1)).current
|
|
263
|
+
const [chromeShown, setChromeShown] = useState(true)
|
|
264
|
+
const hideTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
|
265
|
+
|
|
266
|
+
const poke = useCallback(() => {
|
|
267
|
+
clearTimeout(hideTimer.current)
|
|
268
|
+
setChromeShown(true)
|
|
269
|
+
Animated.timing(chrome, { toValue: 1, duration: 200, useNativeDriver: true }).start()
|
|
270
|
+
if (autoHide === false) return
|
|
271
|
+
|
|
272
|
+
hideTimer.current = setTimeout(() => {
|
|
273
|
+
setChromeShown(false)
|
|
274
|
+
Animated.timing(chrome, { toValue: 0, duration: 300, useNativeDriver: true }).start()
|
|
275
|
+
}, autoHide)
|
|
276
|
+
}, [autoHide, chrome])
|
|
277
|
+
|
|
278
|
+
useEffect(() => {
|
|
279
|
+
poke()
|
|
280
|
+
return () => clearTimeout(hideTimer.current)
|
|
281
|
+
}, [poke])
|
|
282
|
+
|
|
283
|
+
// ------------------------------------------------- A closed book sits centred
|
|
284
|
+
const settled = reader.settled
|
|
285
|
+
const shift = layout.spread && settled ? (reader.atStart ? -pageW / 2 : reader.atEnd ? pageW / 2 : 0) : 0
|
|
286
|
+
const shiftValue = useRef(new Animated.Value(shift)).current
|
|
287
|
+
|
|
288
|
+
useEffect(() => {
|
|
289
|
+
Animated.timing(shiftValue, { toValue: shift, duration: 600, useNativeDriver: true }).start()
|
|
290
|
+
}, [shift, shiftValue])
|
|
291
|
+
|
|
292
|
+
// ------------------------------------------------- Touch: grab, drag, flick, tap
|
|
293
|
+
const geometry = useRef({ stage, pageW, pageH, across, spread: layout.spread, shift })
|
|
294
|
+
geometry.current = { stage, pageW, pageH, across, spread: layout.spread, shift }
|
|
295
|
+
|
|
296
|
+
const grant = useRef<Vec>({ x: 0, y: 0 })
|
|
297
|
+
|
|
298
|
+
/** A point on the stage measured from the top of the spine. */
|
|
299
|
+
const toBook = (p: Vec): Vec => {
|
|
300
|
+
const g = geometry.current
|
|
301
|
+
const left = (g.stage.width - g.pageW * g.across) / 2 + g.shift
|
|
302
|
+
const top = (g.stage.height - g.pageH) / 2
|
|
303
|
+
|
|
304
|
+
return { x: p.x - left - (g.spread ? g.pageW : 0), y: p.y - top }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const responder = useMemo(
|
|
308
|
+
() =>
|
|
309
|
+
PanResponder.create({
|
|
310
|
+
onStartShouldSetPanResponder: () => true,
|
|
311
|
+
onMoveShouldSetPanResponder: () => true,
|
|
312
|
+
onPanResponderTerminationRequest: () => false,
|
|
313
|
+
onPanResponderGrant: (e) => {
|
|
314
|
+
grant.current = { x: e.nativeEvent.locationX, y: e.nativeEvent.locationY }
|
|
315
|
+
poke()
|
|
316
|
+
reader.down(toBook(grant.current), 1)
|
|
317
|
+
},
|
|
318
|
+
onPanResponderMove: (_, g) => {
|
|
319
|
+
reader.move(toBook({ x: grant.current.x + g.dx, y: grant.current.y + g.dy }), 1)
|
|
320
|
+
},
|
|
321
|
+
onPanResponderRelease: (_, g) => {
|
|
322
|
+
const at = { x: grant.current.x + g.dx, y: grant.current.y + g.dy }
|
|
323
|
+
|
|
324
|
+
if (reader.up(toBook(at), 1) !== 'tap') return
|
|
325
|
+
|
|
326
|
+
// A tap on the outer thirds turns the page.
|
|
327
|
+
const width = geometry.current.stage.width
|
|
328
|
+
|
|
329
|
+
if (at.x > (width * 2) / 3) reader.next()
|
|
330
|
+
else if (at.x < width / 3) reader.prev()
|
|
331
|
+
},
|
|
332
|
+
onPanResponderTerminate: () => reader.cancel(),
|
|
333
|
+
}),
|
|
334
|
+
[reader, poke],
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
// ------------------------------------------------- Faces around the opening
|
|
338
|
+
const fold = new FoldLayout(pageW, pageH, active, active ? reader.engine.frame : null, shading)
|
|
339
|
+
const leaves = layout.leaves
|
|
340
|
+
const turnLeaf = active?.leaf ?? null
|
|
341
|
+
const turnDir = active?.dir ?? 1
|
|
342
|
+
const rightLeaf = turnLeaf !== null ? turnLeaf + 1 : flipped
|
|
343
|
+
const leftLeaf = turnLeaf !== null ? turnLeaf - 1 : flipped - 1
|
|
344
|
+
const faces: ReactNode[] = []
|
|
345
|
+
|
|
346
|
+
for (let k = Math.max(0, flipped - 2); k <= Math.min(leaves.length - 1, flipped + 1) && pageW > 0; k++) {
|
|
347
|
+
for (const side of ['front', 'back'] as const) {
|
|
348
|
+
const facesUp = (side === 'front') === (turnDir === 1)
|
|
349
|
+
const role: Role =
|
|
350
|
+
k === turnLeaf
|
|
351
|
+
? facesUp
|
|
352
|
+
? 'turning'
|
|
353
|
+
: 'flap'
|
|
354
|
+
: side === 'front'
|
|
355
|
+
? k === rightLeaf
|
|
356
|
+
? 'right'
|
|
357
|
+
: 'spare'
|
|
358
|
+
: k === leftLeaf && layout.spread
|
|
359
|
+
? 'left'
|
|
360
|
+
: 'spare'
|
|
361
|
+
|
|
362
|
+
if (role === 'spare') continue
|
|
363
|
+
|
|
364
|
+
const onLeft = role === 'left' || (role === 'turning' && turnDir === -1 && !active?.stiff)
|
|
365
|
+
const placement = fold.place(role, side, onLeft)
|
|
366
|
+
const z = { right: 1, left: 1, turning: 3, flap: 6 }[role]
|
|
367
|
+
|
|
368
|
+
faces.push(
|
|
369
|
+
<View key={`${k}:${side}`} style={[placement.clip, { zIndex: z }]} pointerEvents="none">
|
|
370
|
+
<View style={placement.frame}>
|
|
371
|
+
<View style={placement.face}>
|
|
372
|
+
<FaceContent
|
|
373
|
+
face={side === 'front' ? leaves[k].front : leaves[k].back}
|
|
374
|
+
book={book}
|
|
375
|
+
width={pageW}
|
|
376
|
+
side={side === 'front' ? 'right' : 'left'}
|
|
377
|
+
/>
|
|
378
|
+
{placement.bands.map((band) => (
|
|
379
|
+
<View key={band.name} style={[band.style, { opacity: band.opacity }]}>
|
|
380
|
+
<Gradient stops={BAND_STOPS[band.name]} />
|
|
381
|
+
</View>
|
|
382
|
+
))}
|
|
383
|
+
</View>
|
|
384
|
+
</View>
|
|
385
|
+
</View>,
|
|
386
|
+
)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const tint = COVER_THEMES[book.theme ?? 'classic'].stage
|
|
391
|
+
|
|
392
|
+
return (
|
|
393
|
+
<BookLookProvider paper={book.paper} frame={book.frame} fonts={fonts}>
|
|
394
|
+
<View style={[styles.root, { backgroundColor: background ?? tint[2] }, style]}>
|
|
395
|
+
{!background && <StageGlow colors={tint} />}
|
|
396
|
+
|
|
397
|
+
<View
|
|
398
|
+
style={styles.stage}
|
|
399
|
+
onLayout={(e: LayoutChangeEvent) => setStage({ width: e.nativeEvent.layout.width, height: e.nativeEvent.layout.height })}
|
|
400
|
+
pointerEvents="box-only"
|
|
401
|
+
{...responder.panHandlers}
|
|
402
|
+
>
|
|
403
|
+
{pageW > 0 && (
|
|
404
|
+
<Animated.View style={{ width: pageW * across, height: pageH, transform: [{ translateX: shiftValue }] }} pointerEvents="none">
|
|
405
|
+
<View
|
|
406
|
+
style={[
|
|
407
|
+
styles.shadow,
|
|
408
|
+
{
|
|
409
|
+
left: layout.spread && reader.atStart && settled ? '52%' : '2%',
|
|
410
|
+
right: layout.spread && reader.atEnd && settled ? '52%' : '2%',
|
|
411
|
+
},
|
|
412
|
+
]}
|
|
413
|
+
/>
|
|
414
|
+
<View style={[styles.spine, { left: layout.spread ? pageW : 0 }]}>{faces}</View>
|
|
415
|
+
</Animated.View>
|
|
416
|
+
)}
|
|
417
|
+
</View>
|
|
418
|
+
|
|
419
|
+
{controls && (
|
|
420
|
+
<Animated.View style={[styles.bar, styles.top, { opacity: chrome }]} pointerEvents={chromeShown ? 'box-none' : 'none'}>
|
|
421
|
+
<Gradient
|
|
422
|
+
to="bottom"
|
|
423
|
+
stops={[
|
|
424
|
+
{ offset: 0, color: '#000', opacity: 0.6 },
|
|
425
|
+
{ offset: 1, color: '#000', opacity: 0 },
|
|
426
|
+
]}
|
|
427
|
+
/>
|
|
428
|
+
<View style={styles.group}>
|
|
429
|
+
{topLeft}
|
|
430
|
+
<View style={styles.heading}>
|
|
431
|
+
<Text style={styles.title} numberOfLines={1}>
|
|
432
|
+
{book.title}
|
|
433
|
+
</Text>
|
|
434
|
+
{book.author ? (
|
|
435
|
+
<Text style={styles.byline} numberOfLines={1}>
|
|
436
|
+
{labels.byline(book.author)}
|
|
437
|
+
</Text>
|
|
438
|
+
) : null}
|
|
439
|
+
</View>
|
|
440
|
+
</View>
|
|
441
|
+
<View style={styles.group}>{topRight}</View>
|
|
442
|
+
</Animated.View>
|
|
443
|
+
)}
|
|
444
|
+
|
|
445
|
+
{showPages && thumbnails && (
|
|
446
|
+
<PageStrip
|
|
447
|
+
book={book}
|
|
448
|
+
layout={layout}
|
|
449
|
+
flipped={flipped}
|
|
450
|
+
onPick={(target) => {
|
|
451
|
+
reader.go(target)
|
|
452
|
+
setShowPages(false)
|
|
453
|
+
}}
|
|
454
|
+
/>
|
|
455
|
+
)}
|
|
456
|
+
|
|
457
|
+
{controls && (
|
|
458
|
+
<Animated.View style={[styles.bar, styles.bottom, { opacity: chrome }]} pointerEvents={chromeShown ? 'box-none' : 'none'}>
|
|
459
|
+
<Gradient
|
|
460
|
+
to="top"
|
|
461
|
+
stops={[
|
|
462
|
+
{ offset: 0, color: '#000', opacity: 0.6 },
|
|
463
|
+
{ offset: 1, color: '#000', opacity: 0 },
|
|
464
|
+
]}
|
|
465
|
+
/>
|
|
466
|
+
<RoundButton label={labels.previous} disabled={reader.atStart} onPress={() => reader.prev()} icon="M15 6l-6 6 6 6" />
|
|
467
|
+
<Pressable
|
|
468
|
+
onPress={() => thumbnails && setShowPages((s) => !s)}
|
|
469
|
+
style={styles.pages}
|
|
470
|
+
role="button"
|
|
471
|
+
aria-expanded={thumbnails ? showPages : undefined}
|
|
472
|
+
>
|
|
473
|
+
<Text style={styles.pagesText}>{reader.label}</Text>
|
|
474
|
+
</Pressable>
|
|
475
|
+
<RoundButton label={labels.next} disabled={reader.atEnd} onPress={() => reader.next()} icon="M9 6l6 6-6 6" />
|
|
476
|
+
</Animated.View>
|
|
477
|
+
)}
|
|
478
|
+
|
|
479
|
+
{showRotatePrompt && (
|
|
480
|
+
<View style={styles.rotate}>
|
|
481
|
+
<Svg viewBox="0 0 64 64" width={80} height={80}>
|
|
482
|
+
<Rect x="20" y="6" width="24" height="52" rx="4" fill="none" stroke="#fff" strokeWidth={2.5} />
|
|
483
|
+
<Path d="M32 49a2 2 0 1 0 0.01 0" stroke="#fff" strokeWidth={4} strokeLinecap="round" />
|
|
484
|
+
</Svg>
|
|
485
|
+
<Text style={styles.rotateTitle}>{labels.rotateTitle}</Text>
|
|
486
|
+
<Text style={styles.rotateBody}>{labels.rotateBody}</Text>
|
|
487
|
+
<Pressable onPress={() => setPortraitChoice('single')} role="button">
|
|
488
|
+
<Text style={styles.rotateSecondary}>{labels.rotateSingle}</Text>
|
|
489
|
+
</Pressable>
|
|
490
|
+
</View>
|
|
491
|
+
)}
|
|
492
|
+
</View>
|
|
493
|
+
</BookLookProvider>
|
|
494
|
+
)
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
/** A soft glow behind the book, tinted by the cover. */
|
|
498
|
+
function StageGlow({ colors }: { colors: [string, string, string] }) {
|
|
499
|
+
return (
|
|
500
|
+
<Svg style={StyleSheet.absoluteFill} width="100%" height="100%" pointerEvents="none">
|
|
501
|
+
<Defs>
|
|
502
|
+
<RadialGradient id="fbStage" cx="50%" cy="40%" rx="70%" ry="70%">
|
|
503
|
+
<Stop offset="0" stopColor={colors[0]} />
|
|
504
|
+
<Stop offset="0.55" stopColor={colors[1]} />
|
|
505
|
+
<Stop offset="1" stopColor={colors[2]} />
|
|
506
|
+
</RadialGradient>
|
|
507
|
+
</Defs>
|
|
508
|
+
<Rect width="100%" height="100%" fill="url(#fbStage)" />
|
|
509
|
+
</Svg>
|
|
510
|
+
)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** A strip of every spread (or page) to jump around the book. */
|
|
514
|
+
function PageStrip({
|
|
515
|
+
book,
|
|
516
|
+
layout,
|
|
517
|
+
flipped,
|
|
518
|
+
onPick,
|
|
519
|
+
}: {
|
|
520
|
+
book: FlipbookBook
|
|
521
|
+
layout: BookLayout
|
|
522
|
+
flipped: number
|
|
523
|
+
onPick: (flipped: number) => void
|
|
524
|
+
}) {
|
|
525
|
+
const width = 54
|
|
526
|
+
|
|
527
|
+
return (
|
|
528
|
+
<View style={styles.strip}>
|
|
529
|
+
<ScrollView horizontal contentContainerStyle={styles.stripList} showsHorizontalScrollIndicator={false}>
|
|
530
|
+
{layout.stops().map((stop) => (
|
|
531
|
+
<Pressable
|
|
532
|
+
key={stop.flipped}
|
|
533
|
+
onPress={() => onPick(stop.flipped)}
|
|
534
|
+
role="button"
|
|
535
|
+
aria-label={`Go to ${BookLayout.describe(stop.faces)}`}
|
|
536
|
+
style={[styles.stop, stop.flipped === flipped && styles.stopCurrent]}
|
|
537
|
+
>
|
|
538
|
+
{stop.faces.map((face, i) => (
|
|
539
|
+
<FaceContent key={i} face={face} book={book} width={width} side={i === 0 && stop.faces.length > 1 ? 'left' : 'right'} />
|
|
540
|
+
))}
|
|
541
|
+
</Pressable>
|
|
542
|
+
))}
|
|
543
|
+
</ScrollView>
|
|
544
|
+
</View>
|
|
545
|
+
)
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function RoundButton({ label, icon, disabled, onPress }: { label: string; icon: string; disabled?: boolean; onPress: () => void }) {
|
|
549
|
+
return (
|
|
550
|
+
<Pressable onPress={onPress} disabled={disabled} role="button" aria-label={label} style={[styles.button, disabled && styles.disabled]}>
|
|
551
|
+
<Svg viewBox="0 0 24 24" width={20} height={20}>
|
|
552
|
+
<Path d={icon} stroke="#fff" strokeWidth={2} fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
|
553
|
+
</Svg>
|
|
554
|
+
</Pressable>
|
|
555
|
+
)
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const styles = StyleSheet.create({
|
|
559
|
+
root: { flex: 1, overflow: 'hidden', userSelect: 'none' },
|
|
560
|
+
stage: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
|
561
|
+
spine: { position: 'absolute', top: 0, width: 0, height: 0 },
|
|
562
|
+
shadow: { position: 'absolute', bottom: -10, height: 20, borderRadius: 999, backgroundColor: 'rgba(0,0,0,0.25)' },
|
|
563
|
+
bar: { position: 'absolute', left: 0, right: 0, flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 16 },
|
|
564
|
+
top: { top: 0, justifyContent: 'space-between', paddingVertical: 12 },
|
|
565
|
+
bottom: { bottom: 0, justifyContent: 'center', paddingTop: 24, paddingBottom: 16 },
|
|
566
|
+
group: { flexDirection: 'row', alignItems: 'center', gap: 8, flexShrink: 1 },
|
|
567
|
+
heading: { flexShrink: 1 },
|
|
568
|
+
title: { color: '#fff', fontSize: 18 },
|
|
569
|
+
byline: { color: 'rgba(255,255,255,0.6)', fontSize: 12 },
|
|
570
|
+
button: { padding: 10, borderRadius: 999, backgroundColor: 'rgba(255,255,255,0.1)' },
|
|
571
|
+
disabled: { opacity: 0.3 },
|
|
572
|
+
pages: {
|
|
573
|
+
minWidth: 160,
|
|
574
|
+
alignItems: 'center',
|
|
575
|
+
paddingVertical: 8,
|
|
576
|
+
paddingHorizontal: 16,
|
|
577
|
+
borderRadius: 999,
|
|
578
|
+
backgroundColor: 'rgba(255,255,255,0.1)',
|
|
579
|
+
},
|
|
580
|
+
pagesText: { color: 'rgba(255,255,255,0.9)', fontSize: 14, fontVariant: ['tabular-nums'] },
|
|
581
|
+
strip: { position: 'absolute', left: 16, right: 16, bottom: 80, borderRadius: 16, backgroundColor: 'rgba(0,0,0,0.6)' },
|
|
582
|
+
stripList: { gap: 12, padding: 12 },
|
|
583
|
+
stop: { flexDirection: 'row', borderWidth: 2, borderColor: 'transparent', opacity: 0.8 },
|
|
584
|
+
stopCurrent: { borderColor: '#fcd34d', opacity: 1 },
|
|
585
|
+
rotate: {
|
|
586
|
+
position: 'absolute',
|
|
587
|
+
top: 0,
|
|
588
|
+
right: 0,
|
|
589
|
+
bottom: 0,
|
|
590
|
+
left: 0,
|
|
591
|
+
alignItems: 'center',
|
|
592
|
+
justifyContent: 'center',
|
|
593
|
+
gap: 16,
|
|
594
|
+
paddingHorizontal: 32,
|
|
595
|
+
backgroundColor: 'rgba(0,0,0,0.85)',
|
|
596
|
+
},
|
|
597
|
+
rotateTitle: { color: '#fff', fontSize: 24, textAlign: 'center' },
|
|
598
|
+
rotateBody: { color: 'rgba(255,255,255,0.7)', fontSize: 14, textAlign: 'center' },
|
|
599
|
+
rotateSecondary: { color: 'rgba(255,255,255,0.8)', fontSize: 14, textDecorationLine: 'underline' },
|
|
600
|
+
})
|
package/src/Gradient.tsx
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useId } from 'react'
|
|
2
|
+
import { StyleSheet, type StyleProp, type ViewStyle } from 'react-native'
|
|
3
|
+
import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg'
|
|
4
|
+
|
|
5
|
+
export interface GradientStop {
|
|
6
|
+
offset: number
|
|
7
|
+
color: string
|
|
8
|
+
opacity?: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** A linear gradient filling its box, drawn with react-native-svg so it needs no other native module. */
|
|
12
|
+
export function Gradient({
|
|
13
|
+
stops,
|
|
14
|
+
to = 'right',
|
|
15
|
+
style = StyleSheet.absoluteFill,
|
|
16
|
+
}: {
|
|
17
|
+
stops: GradientStop[]
|
|
18
|
+
/** Where the last stop lies. */
|
|
19
|
+
to?: 'right' | 'left' | 'bottom' | 'top'
|
|
20
|
+
style?: StyleProp<ViewStyle>
|
|
21
|
+
}) {
|
|
22
|
+
const id = `g${useId().replace(/[^a-zA-Z0-9]/g, '')}`
|
|
23
|
+
const [x1, y1, x2, y2] = { right: [0, 0, 1, 0], left: [1, 0, 0, 0], bottom: [0, 0, 0, 1], top: [0, 1, 0, 0] }[to]
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<Svg style={style} width="100%" height="100%" pointerEvents="none">
|
|
27
|
+
<Defs>
|
|
28
|
+
<LinearGradient id={id} x1={x1} y1={y1} x2={x2} y2={y2}>
|
|
29
|
+
{stops.map((stop, i) => (
|
|
30
|
+
<Stop key={i} offset={stop.offset} stopColor={stop.color} stopOpacity={stop.opacity ?? 1} />
|
|
31
|
+
))}
|
|
32
|
+
</LinearGradient>
|
|
33
|
+
</Defs>
|
|
34
|
+
<Rect width="100%" height="100%" fill={`url(#${id})`} />
|
|
35
|
+
</Svg>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Stops for a colour given as `rgba(r,g,b,a)`, split so svg gets the alpha separately. */
|