@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/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
|
+
})
|
package/src/require.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
declare const require: (id: string) => any
|