@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/src/look.tsx ADDED
@@ -0,0 +1,110 @@
1
+ import { createContext, useContext, type ReactNode } from 'react'
2
+ import { Platform, type ImageSourcePropType } from 'react-native'
3
+ import type { FrameStyle, PaperColor } from '@toneflix/flipbook-core'
4
+
5
+ /** How one paper colour prints: its ink, the tints frames mix with it, and its texture tiles. Mirrors the web's .fb-paper-* styles. */
6
+ export interface PaperLook {
7
+ background: string
8
+ /** Ink as r, g, b, so frames can print it at any strength. */
9
+ ink: [number, number, number]
10
+ frameTint: string
11
+ plateTint: string
12
+ plateLow: string
13
+ plateHigh: string
14
+ texture: ImageSourcePropType
15
+ textureOpacity: number
16
+ grain: ImageSourcePropType
17
+ grainOpacity: number
18
+ /** Soft light from the top left, and the darker edges of the sheet. */
19
+ light: string
20
+ vignette: string
21
+ }
22
+
23
+ const fine = { texture: require('../assets/textures/paper-fine@2x.webp'), grain: require('../assets/textures/grain-fine@2x.webp') }
24
+
25
+ const ivory: PaperLook = {
26
+ background: '#fdf9f0',
27
+ ink: [61, 55, 47],
28
+ frameTint: 'rgba(255,255,255,0.4)',
29
+ plateTint: 'rgba(90,70,40,0.03)',
30
+ plateLow: 'rgba(90,70,40,0.12)',
31
+ plateHigh: 'rgba(255,255,255,0.6)',
32
+ ...fine,
33
+ textureOpacity: 0.35,
34
+ grainOpacity: 0.45,
35
+ light: 'rgba(255,255,255,0.5)',
36
+ vignette: 'rgba(110,90,60,0.08)',
37
+ }
38
+
39
+ export const PAPERS: Record<PaperColor, PaperLook> = {
40
+ ivory,
41
+ white: { ...ivory, background: '#ffffff', frameTint: 'rgba(150,125,90,0.08)' },
42
+ kraft: {
43
+ background: '#d8c4a2',
44
+ ink: [61, 47, 29],
45
+ frameTint: 'rgba(255,248,235,0.35)',
46
+ plateTint: 'rgba(90,70,40,0.03)',
47
+ plateLow: 'rgba(80,50,20,0.16)',
48
+ plateHigh: 'rgba(255,245,225,0.35)',
49
+ texture: require('../assets/textures/paper-kraft@2x.webp'),
50
+ textureOpacity: 0.45,
51
+ grain: require('../assets/textures/grain-kraft@2x.webp'),
52
+ grainOpacity: 0.35,
53
+ light: 'rgba(255,255,255,0.18)',
54
+ vignette: 'rgba(80,50,20,0.12)',
55
+ },
56
+ charcoal: {
57
+ background: '#2a2826',
58
+ ink: [232, 226, 214],
59
+ frameTint: 'rgba(255,255,255,0.07)',
60
+ plateTint: 'rgba(0,0,0,0.08)',
61
+ plateLow: 'rgba(0,0,0,0.35)',
62
+ plateHigh: 'rgba(255,255,255,0.07)',
63
+ texture: require('../assets/textures/paper-charcoal@2x.webp'),
64
+ textureOpacity: 0.25,
65
+ grain: require('../assets/textures/grain-charcoal@2x.webp'),
66
+ grainOpacity: 0.22,
67
+ light: 'rgba(255,255,255,0.05)',
68
+ vignette: 'rgba(0,0,0,0.25)',
69
+ },
70
+ }
71
+
72
+ /** The ink of a paper at a strength from 0 to 1. */
73
+ export const ink = (look: PaperLook, alpha = 1) => `rgba(${look.ink.join(',')},${alpha})`
74
+
75
+ /** Font families for page text. Leave out `serifItalic` to slant `serif` instead. */
76
+ export interface BookFonts {
77
+ serif?: string
78
+ serifItalic?: string
79
+ sans?: string
80
+ }
81
+
82
+ const DEFAULT_SERIF = Platform.select({ ios: 'Georgia', android: 'serif', default: 'Georgia, serif' })
83
+
84
+ export interface BookLook {
85
+ paper: PaperColor
86
+ frame: FrameStyle
87
+ fonts: BookFonts
88
+ }
89
+
90
+ const BookLookContext = createContext<BookLook>({ paper: 'ivory', frame: 'none', fonts: {} })
91
+
92
+ /** The paper, default frame and fonts for every page rendered inside. The viewer sets it from the book. */
93
+ export function BookLookProvider({ paper = 'ivory', frame = 'none', fonts = {}, children }: Partial<BookLook> & { children: ReactNode }) {
94
+ return <BookLookContext.Provider value={{ paper, frame, fonts }}>{children}</BookLookContext.Provider>
95
+ }
96
+
97
+ export const useBookLook = () => useContext(BookLookContext)
98
+
99
+ export const usePaper = () => PAPERS[useBookLook().paper] ?? ivory
100
+
101
+ /** Text styles for the book's serif face, upright or italic. */
102
+ export function useSerif() {
103
+ const { fonts } = useBookLook()
104
+ const serif = fonts.serif ?? DEFAULT_SERIF
105
+
106
+ return {
107
+ upright: { fontFamily: serif },
108
+ italic: fonts.serifItalic ? { fontFamily: fonts.serifItalic } : { fontFamily: serif, fontStyle: 'italic' as const },
109
+ }
110
+ }
@@ -0,0 +1,32 @@
1
+ type ScreenOrientationModule = {
2
+ OrientationLock: { LANDSCAPE: number }
3
+ getOrientationLockAsync(): Promise<number>
4
+ lockAsync(lock: number): Promise<void>
5
+ }
6
+
7
+ /** `expo-screen-orientation`, when the app has it installed. */
8
+ const screenOrientation: ScreenOrientationModule | null = (() => {
9
+ try {
10
+ return require('expo-screen-orientation')
11
+ } catch {
12
+ return null
13
+ }
14
+ })()
15
+
16
+ /**
17
+ * Locks the screen to landscape and resolves to a function that restores the previous lock, or
18
+ * to null when the screen can't be locked.
19
+ */
20
+ export async function lockLandscape(): Promise<(() => void) | null> {
21
+ if (!screenOrientation) return null
22
+
23
+ try {
24
+ const previous = await screenOrientation.getOrientationLockAsync()
25
+
26
+ await screenOrientation.lockAsync(screenOrientation.OrientationLock.LANDSCAPE)
27
+
28
+ return () => void screenOrientation.lockAsync(previous).catch(() => {})
29
+ } catch {
30
+ return null
31
+ }
32
+ }
package/src/pages.tsx ADDED
@@ -0,0 +1,440 @@
1
+ import { useId, type ReactNode } from 'react'
2
+ import { Image, Pressable, StyleSheet, Text, View, type ViewStyle } from 'react-native'
3
+ import Svg, { Circle, Defs, Path, RadialGradient, Rect, Stop } from 'react-native-svg'
4
+ import { COVER_THEMES, PLACED_LAYOUTS, photoFrame, type FlipbookBook, type FlipbookPage, type PageBox } from '@toneflix/flipbook-core'
5
+ import { Gradient } from './Gradient'
6
+ import { ink, useBookLook, usePaper, useSerif, type PaperLook } from './look'
7
+ import { PrintedPhoto } from './PrintedPhoto'
8
+
9
+ /** Pages are 3:4, like the web's. */
10
+ export const PAGE_RATIO = 3 / 4
11
+
12
+ type Side = 'left' | 'right'
13
+
14
+ export interface PageViewProps {
15
+ page: FlipbookPage
16
+ /** Page width; the height follows the 3:4 page shape. */
17
+ width: number
18
+ number?: number
19
+ side?: Side
20
+ showImages?: boolean
21
+ /** Editor: makes each photo slot pressable. */
22
+ onSlotPress?: (slot: number) => void
23
+ activeSlot?: number | null
24
+ /** Editor: colour of the highlighted slot. */
25
+ accent?: string
26
+ }
27
+
28
+ /**
29
+ * The inside of one book page, `width` wide. Sizes are in units of 1% of the width, so the same
30
+ * page looks right as a thumbnail and full screen.
31
+ */
32
+ export function PageView({ page, width, number, side, showImages = true, onSlotPress, activeSlot, accent = '#00afff' }: PageViewProps) {
33
+ const look = usePaper()
34
+ const serif = useSerif()
35
+ const unit = width / 100
36
+ const { layout, caption, photos } = page
37
+ const bookFrame = useBookLook().frame
38
+ const placed = PLACED_LAYOUTS[layout]
39
+ const captionStyle = [serif.italic, styles.caption, { fontSize: 3.8 * unit, lineHeight: 5.2 * unit, color: ink(look) }]
40
+
41
+ const slot = (index: number, style: ViewStyle, fit: 'cover' | 'contain' = 'cover') => {
42
+ const photo = photos[index] ?? null
43
+ const frame = photoFrame(page, photo, bookFrame)
44
+
45
+ return (
46
+ <Pressable
47
+ key={index}
48
+ disabled={!onSlotPress}
49
+ onPress={() => onSlotPress?.(index)}
50
+ role={onSlotPress ? 'button' : undefined}
51
+ aria-label={onSlotPress ? `Photo slot ${index + 1}` : undefined}
52
+ style={[style, fit === 'cover' && styles.clip]}
53
+ >
54
+ {photo ? (
55
+ <PrintedPhoto photo={photo} frame={frame} unit={unit} fit={fit} show={showImages} />
56
+ ) : (
57
+ <View
58
+ style={[
59
+ StyleSheet.absoluteFill,
60
+ styles.empty,
61
+ { backgroundColor: ink(look, 0.1) },
62
+ onSlotPress && { borderWidth: 0.4 * unit, borderColor: ink(look, 0.3) },
63
+ ]}
64
+ >
65
+ {onSlotPress && <Text style={{ fontSize: 3.2 * unit, color: ink(look, 0.7) }}>Add a photo</Text>}
66
+ </View>
67
+ )}
68
+ {onSlotPress && activeSlot === index && (
69
+ <View pointerEvents="none" style={[StyleSheet.absoluteFill, { borderWidth: 0.8 * unit, borderColor: accent }]} />
70
+ )}
71
+ </Pressable>
72
+ )
73
+ }
74
+
75
+ const boxStyle = ({ x, y, width: w, height: h, rotate }: PageBox): ViewStyle => ({
76
+ position: 'absolute',
77
+ left: x * unit,
78
+ top: y * unit,
79
+ width: w * unit,
80
+ height: h * unit,
81
+ transform: rotate ? [{ rotate: `${rotate}deg` }] : undefined,
82
+ })
83
+
84
+ const pageCaption = caption ? <Text style={captionStyle}>{caption}</Text> : null
85
+
86
+ return (
87
+ <Paper width={width} side={side}>
88
+ {layout === 'single' && (
89
+ <>
90
+ {slot(0, StyleSheet.absoluteFill)}
91
+ {caption ? (
92
+ <View
93
+ pointerEvents="none"
94
+ style={[styles.overlay, { paddingTop: 14 * unit, paddingHorizontal: 7 * unit, paddingBottom: 7 * unit }]}
95
+ >
96
+ <Gradient
97
+ to="top"
98
+ stops={[
99
+ { offset: 0, color: '#000', opacity: 0.7 },
100
+ { offset: 0.5, color: '#000', opacity: 0.3 },
101
+ { offset: 1, color: '#000', opacity: 0 },
102
+ ]}
103
+ />
104
+ <Text style={[serif.italic, { fontSize: 4.2 * unit, lineHeight: 5.7 * unit, color: '#fff' }]}>{caption}</Text>
105
+ </View>
106
+ ) : null}
107
+ </>
108
+ )}
109
+
110
+ {layout === 'framed' && (
111
+ <View style={[styles.fill, { paddingTop: 10 * unit, paddingHorizontal: 9 * unit, paddingBottom: 6 * unit }]}>
112
+ {slot(0, styles.flex, 'contain')}
113
+ <Text style={[captionStyle, { marginTop: 5 * unit, minHeight: 6 * unit, fontSize: 4.2 * unit, lineHeight: 5.7 * unit }]}>
114
+ {caption}
115
+ </Text>
116
+ </View>
117
+ )}
118
+
119
+ {layout === 'pair' && (
120
+ <View style={[styles.fill, { padding: 7 * unit, gap: 4 * unit }]}>
121
+ {slot(0, styles.flex)}
122
+ {slot(1, styles.flex)}
123
+ {pageCaption}
124
+ </View>
125
+ )}
126
+
127
+ {layout === 'grid' && (
128
+ <View style={[styles.fill, { padding: 7 * unit, gap: 3 * unit }]}>
129
+ {[0, 2].map((first) => (
130
+ <View key={first} style={[styles.row, { gap: 3 * unit }]}>
131
+ {slot(first, styles.flex)}
132
+ {slot(first + 1, styles.flex)}
133
+ </View>
134
+ ))}
135
+ {pageCaption}
136
+ </View>
137
+ )}
138
+
139
+ {layout === 'collage' && (
140
+ <View style={[styles.fill, { padding: 7 * unit, gap: 3 * unit }]}>
141
+ {slot(0, { flex: 1.4 })}
142
+ <View style={[styles.row, { gap: 3 * unit }]}>
143
+ {slot(1, styles.flex)}
144
+ {slot(2, styles.flex)}
145
+ </View>
146
+ {pageCaption}
147
+ </View>
148
+ )}
149
+
150
+ {placed && (
151
+ <>
152
+ {placed.slots.map((box, i) => slot(i, { ...boxStyle(box), ...(placed.loose && styles.loose) }))}
153
+ {caption ? (
154
+ <View pointerEvents="none" style={[boxStyle(placed.caption), !placed.prose && styles.centered]}>
155
+ <Text
156
+ style={placed.prose ? [serif.upright, { fontSize: 4.4 * unit, lineHeight: 6.8 * unit, color: ink(look) }] : captionStyle}
157
+ >
158
+ {caption}
159
+ </Text>
160
+ </View>
161
+ ) : null}
162
+ </>
163
+ )}
164
+
165
+ {layout === 'text' && (
166
+ <View style={[styles.fill, styles.centered, { paddingVertical: 14 * unit, paddingHorizontal: 12 * unit }]}>
167
+ <Text
168
+ style={[serif.upright, { fontSize: 18 * unit, lineHeight: 16 * unit, height: 10 * unit, color: ink(look, 0.3) }]}
169
+ accessible={false}
170
+ >
171
+ “
172
+ </Text>
173
+ <Text
174
+ style={[serif.italic, styles.caption, { marginTop: 4 * unit, fontSize: 6 * unit, lineHeight: 8.4 * unit, color: ink(look) }]}
175
+ >
176
+ {caption || (onSlotPress ? 'Write something in the caption box below.' : '')}
177
+ </Text>
178
+ </View>
179
+ )}
180
+
181
+ {number !== undefined && layout !== 'single' && (
182
+ <Text
183
+ style={[
184
+ serif.upright,
185
+ styles.number,
186
+ side === 'left' ? styles.left : styles.right,
187
+ { paddingHorizontal: 5 * unit, paddingBottom: 2.6 * unit, fontSize: 2.8 * unit, color: ink(look, 0.6) },
188
+ ]}
189
+ >
190
+ {number}
191
+ </Text>
192
+ )}
193
+ </Paper>
194
+ )
195
+ }
196
+
197
+ /** A sheet of the book's paper: texture under the content, grain and page light over it. */
198
+ export function Paper({ width, side, children, style }: { width: number; side?: Side; children?: ReactNode; style?: ViewStyle }) {
199
+ const look = usePaper()
200
+
201
+ return (
202
+ <View style={[styles.page, { width, height: width / PAGE_RATIO, backgroundColor: look.background }, style]}>
203
+ <Image source={look.texture} resizeMode="repeat" style={[StyleSheet.absoluteFill, styles.tile, { opacity: look.textureOpacity }]} />
204
+ <PageLight look={look} />
205
+ {children}
206
+ <View pointerEvents="none" style={StyleSheet.absoluteFill}>
207
+ <Image source={look.grain} resizeMode="repeat" style={[StyleSheet.absoluteFill, styles.tile, { opacity: look.grainOpacity }]} />
208
+ {side && <Gutter side={side} />}
209
+ </View>
210
+ </View>
211
+ )
212
+ }
213
+
214
+ /** Soft light from the top left and darker edges, like a real sheet. */
215
+ function PageLight({ look }: { look: PaperLook }) {
216
+ const id = useId().replace(/[^a-zA-Z0-9]/g, '')
217
+
218
+ return (
219
+ <Svg style={StyleSheet.absoluteFill} width="100%" height="100%" pointerEvents="none">
220
+ <Defs>
221
+ <RadialGradient id={`light${id}`} cx="30%" cy="20%" rx="55%" ry="55%">
222
+ <Stop offset="0" stopColor={look.light} />
223
+ <Stop offset="1" stopColor={look.light} stopOpacity={0} />
224
+ </RadialGradient>
225
+ <RadialGradient id={`vignette${id}`} cx="50%" cy="50%" rx="70%" ry="70%">
226
+ <Stop offset="0.6" stopColor={look.vignette} stopOpacity={0} />
227
+ <Stop offset="1" stopColor={look.vignette} />
228
+ </RadialGradient>
229
+ </Defs>
230
+ <Rect width="100%" height="100%" fill={`url(#light${id})`} />
231
+ <Rect width="100%" height="100%" fill={`url(#vignette${id})`} />
232
+ </Svg>
233
+ )
234
+ }
235
+
236
+ /** The page curving into the spine on a spread, and the sheen where it rises. */
237
+ function Gutter({ side }: { side: Side }) {
238
+ const to = side === 'right' ? 'right' : 'left'
239
+ const shade = (a: number, offset: number) => ({ offset, color: 'rgb(20,12,4)', opacity: a })
240
+ const sheen = (a: number, offset: number) => ({ offset, color: 'rgb(255,250,240)', opacity: a })
241
+
242
+ return (
243
+ <>
244
+ <Gradient
245
+ to={to}
246
+ stops={[shade(0.5, 0), shade(0.3, 0.015), shade(0.14, 0.04), shade(0.05, 0.08), shade(0, 0.15), shade(0, 0.94), shade(0.07, 1)]}
247
+ />
248
+ <Gradient to={to} stops={[sheen(0, 0.06), sheen(0.07, 0.13), sheen(0.02, 0.26), sheen(0, 0.4)]} />
249
+ </>
250
+ )
251
+ }
252
+
253
+ /** Cloth for a cover, with the hinge shadow along the spine. */
254
+ function Cloth({ book, width, hinge, children }: { book: Pick<FlipbookBook, 'theme'>; width: number; hinge: Side; children?: ReactNode }) {
255
+ const theme = COVER_THEMES[book.theme ?? 'classic']
256
+ const id = useId().replace(/[^a-zA-Z0-9]/g, '')
257
+ const shade = (a: number, offset: number) => ({ offset, color: '#000', opacity: a })
258
+
259
+ return (
260
+ <View style={[styles.page, { width, height: width / PAGE_RATIO, backgroundColor: theme.cloth }]}>
261
+ <Svg style={StyleSheet.absoluteFill} width="100%" height="100%" pointerEvents="none">
262
+ <Defs>
263
+ <RadialGradient id={`sheen${id}`} cx="30%" cy="20%" rx="45%" ry="45%">
264
+ <Stop offset="0" stopColor="#fff" stopOpacity={0.08} />
265
+ <Stop offset="1" stopColor="#fff" stopOpacity={0} />
266
+ </RadialGradient>
267
+ </Defs>
268
+ <Rect width="100%" height="100%" fill={`url(#sheen${id})`} />
269
+ </Svg>
270
+ <Gradient
271
+ to={hinge === 'left' ? 'right' : 'left'}
272
+ style={[styles.hinge, hinge === 'left' ? styles.left : styles.right, { width: width * 0.04 }]}
273
+ stops={[shade(0.35, 0), shade(0.1, 0.5), shade(0, 1)]}
274
+ />
275
+ {children}
276
+ </View>
277
+ )
278
+ }
279
+
280
+ export interface CoverViewProps {
281
+ book: Pick<FlipbookBook, 'title' | 'subtitle' | 'theme' | 'cover' | 'kicker'>
282
+ width: number
283
+ showImages?: boolean
284
+ }
285
+
286
+ /** The front cover: cloth with the title and an inset photo. */
287
+ export function CoverView({ book, width, showImages = true }: CoverViewProps) {
288
+ const color = COVER_THEMES[book.theme ?? 'classic'].ink
289
+ const serif = useSerif()
290
+ const unit = width / 100
291
+
292
+ return (
293
+ <Cloth book={book} width={width} hinge="left">
294
+ <View style={[styles.panel, { margin: 6 * unit, paddingVertical: 8 * unit, paddingHorizontal: 6 * unit, borderColor: color }]}>
295
+ {book.kicker ? (
296
+ <Text style={[styles.kicker, { fontSize: 2.6 * unit, letterSpacing: 0.78 * unit, color }]}>{book.kicker.toUpperCase()}</Text>
297
+ ) : null}
298
+ <Text
299
+ style={[
300
+ serif.upright,
301
+ styles.center,
302
+ { marginTop: 5 * unit, fontSize: 9 * unit, lineHeight: 9.5 * unit, fontWeight: '500', color },
303
+ ]}
304
+ >
305
+ {book.title}
306
+ </Text>
307
+ {book.cover ? (
308
+ <View style={[styles.coverPhoto, { marginTop: 7 * unit, borderWidth: 0.8 * unit, borderColor: color }]}>
309
+ {showImages && (
310
+ <Image
311
+ source={{ uri: book.cover.src }}
312
+ style={StyleSheet.absoluteFill}
313
+ resizeMode="cover"
314
+ aria-label={book.cover.alt ?? undefined}
315
+ />
316
+ )}
317
+ </View>
318
+ ) : book.subtitle ? (
319
+ <Text style={[serif.italic, styles.center, { marginTop: 6 * unit, fontSize: 4 * unit, color, opacity: 0.8 }]}>
320
+ {book.subtitle}
321
+ </Text>
322
+ ) : null}
323
+ </View>
324
+ </Cloth>
325
+ )
326
+ }
327
+
328
+ export function BackCoverView({ book, width }: { book: Pick<FlipbookBook, 'theme' | 'imprint'>; width: number }) {
329
+ const color = COVER_THEMES[book.theme ?? 'classic'].ink
330
+ const unit = width / 100
331
+
332
+ return (
333
+ <Cloth book={book} width={width} hinge="right">
334
+ <View style={[styles.imprint, { bottom: 10 * unit }]}>
335
+ <Ornament size={9 * unit} color={color} />
336
+ {book.imprint ? (
337
+ <Text style={[styles.kicker, { marginTop: 2 * unit, fontSize: 2.4 * unit, letterSpacing: 0.72 * unit, color, opacity: 0.6 }]}>
338
+ {book.imprint.toUpperCase()}
339
+ </Text>
340
+ ) : null}
341
+ </View>
342
+ </Cloth>
343
+ )
344
+ }
345
+
346
+ /** Inside the front cover: a title page. */
347
+ export function TitlePageView({
348
+ book,
349
+ width,
350
+ side,
351
+ }: {
352
+ book: Pick<FlipbookBook, 'title' | 'subtitle' | 'author' | 'year'>
353
+ width: number
354
+ side?: Side
355
+ }) {
356
+ const look = usePaper()
357
+ const serif = useSerif()
358
+ const unit = width / 100
359
+ const credit = [book.author, book.year].filter(Boolean).join(' · ')
360
+
361
+ return (
362
+ <Paper width={width} side={side}>
363
+ <View style={[styles.fill, styles.centered, { padding: 12 * unit }]}>
364
+ <Ornament size={9 * unit} color={ink(look)} />
365
+ <Text
366
+ style={[serif.upright, styles.center, { marginTop: 6 * unit, fontSize: 7.5 * unit, lineHeight: 8.3 * unit, color: ink(look) }]}
367
+ >
368
+ {book.title}
369
+ </Text>
370
+ {book.subtitle ? (
371
+ <Text
372
+ style={[serif.italic, styles.center, { marginTop: 4 * unit, fontSize: 4 * unit, lineHeight: 5.8 * unit, color: ink(look) }]}
373
+ >
374
+ {book.subtitle}
375
+ </Text>
376
+ ) : null}
377
+ <View style={{ marginTop: 7 * unit, height: StyleSheet.hairlineWidth, width: 20 * unit, backgroundColor: ink(look, 0.3) }} />
378
+ {credit ? (
379
+ <Text style={[styles.kicker, { marginTop: 5 * unit, fontSize: 2.6 * unit, letterSpacing: 0.52 * unit, color: ink(look) }]}>
380
+ {credit.toUpperCase()}
381
+ </Text>
382
+ ) : null}
383
+ </View>
384
+ </Paper>
385
+ )
386
+ }
387
+
388
+ /** Inside the back cover, with the book's closing line. */
389
+ export function ColophonView({ book, width, side }: { book: Pick<FlipbookBook, 'colophon'>; width: number; side?: Side }) {
390
+ const look = usePaper()
391
+ const serif = useSerif()
392
+ const unit = width / 100
393
+
394
+ return (
395
+ <Paper width={width} side={side}>
396
+ <View style={[styles.fill, { alignItems: 'center', justifyContent: 'flex-end', padding: 12 * unit }]}>
397
+ {book.colophon ? (
398
+ <Text style={[serif.italic, styles.center, { fontSize: 3.6 * unit, color: ink(look, 0.9) }]}>{book.colophon}</Text>
399
+ ) : null}
400
+ </View>
401
+ </Paper>
402
+ )
403
+ }
404
+
405
+ export function BlankPageView({ width, side }: { width: number; side?: Side }) {
406
+ return <Paper width={width} side={side} />
407
+ }
408
+
409
+ function Ornament({ size, color }: { size: number; color: string }) {
410
+ return (
411
+ <Svg viewBox="0 0 40 40" width={size} height={size}>
412
+ <Circle cx="20" cy="20" r="18.5" fill="none" stroke={color} strokeOpacity={0.6} />
413
+ <Path d="M20 9 L 27 20 L 20 31 L 13 20 Z" fill="none" stroke={color} strokeWidth={1.4} strokeLinejoin="round" />
414
+ <Circle cx="20" cy="20" r="2.2" fill={color} />
415
+ </Svg>
416
+ )
417
+ }
418
+
419
+ const styles = StyleSheet.create({
420
+ page: { overflow: 'hidden' },
421
+ tile: { width: '100%', height: '100%' },
422
+ fill: { position: 'absolute', top: 0, right: 0, bottom: 0, left: 0 },
423
+ flex: { flex: 1 },
424
+ row: { flex: 1, flexDirection: 'row' },
425
+ centered: { alignItems: 'center', justifyContent: 'center' },
426
+ center: { textAlign: 'center' },
427
+ clip: { overflow: 'hidden' },
428
+ loose: { boxShadow: '0 2px 6px rgba(0,0,0,0.25)' },
429
+ empty: { alignItems: 'center', justifyContent: 'center', borderStyle: 'dashed' },
430
+ overlay: { position: 'absolute', left: 0, right: 0, bottom: 0 },
431
+ caption: { textAlign: 'center' },
432
+ number: { position: 'absolute', bottom: 0 },
433
+ left: { left: 0 },
434
+ right: { right: 0 },
435
+ hinge: { position: 'absolute', top: 0, bottom: 0 },
436
+ panel: { flex: 1, alignItems: 'center', borderWidth: StyleSheet.hairlineWidth },
437
+ kicker: { textAlign: 'center', opacity: 0.7 },
438
+ coverPhoto: { width: '100%', flex: 1, maxHeight: '62%', overflow: 'hidden' },
439
+ imprint: { position: 'absolute', left: 0, right: 0, alignItems: 'center' },
440
+ })
@@ -0,0 +1 @@
1
+ declare const require: (id: string) => any