@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
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import { useId, useState } from 'react'
|
|
2
|
+
import { Image, StyleSheet, View, type LayoutChangeEvent, type ViewStyle } from 'react-native'
|
|
3
|
+
import Svg, {
|
|
4
|
+
ClipPath,
|
|
5
|
+
Defs,
|
|
6
|
+
Ellipse,
|
|
7
|
+
G,
|
|
8
|
+
Image as SvgImage,
|
|
9
|
+
LinearGradient as SvgGradient,
|
|
10
|
+
Path,
|
|
11
|
+
Pattern,
|
|
12
|
+
Polygon,
|
|
13
|
+
Rect,
|
|
14
|
+
Circle,
|
|
15
|
+
Stop,
|
|
16
|
+
} from 'react-native-svg'
|
|
17
|
+
import { BANDED_FRAMES, type FlipbookPhoto, type FrameStyle } from '@toneflix/flipbook-core'
|
|
18
|
+
import { Gradient } from './Gradient'
|
|
19
|
+
import { ink, usePaper, type PaperLook } from './look'
|
|
20
|
+
|
|
21
|
+
type Box = { left: number; top: number; width: number; height: number }
|
|
22
|
+
|
|
23
|
+
const BANDS = BANDED_FRAMES
|
|
24
|
+
|
|
25
|
+
/** The space inside the frame line (pad) and the frame line itself, in page units. Mirrors the web's .frame-* styles. */
|
|
26
|
+
function measure(frame: FrameStyle, unit: number) {
|
|
27
|
+
const band = BANDS[frame]
|
|
28
|
+
|
|
29
|
+
if (band) return { pad: (band + 2.6) * unit, line: 0 }
|
|
30
|
+
|
|
31
|
+
switch (frame) {
|
|
32
|
+
case 'hairline':
|
|
33
|
+
return { pad: 1.6 * unit, line: Math.max(1, 0.22 * unit) }
|
|
34
|
+
case 'double':
|
|
35
|
+
return { pad: 1.4 * unit, line: Math.max(3, 0.7 * unit) }
|
|
36
|
+
case 'mat':
|
|
37
|
+
return { pad: 4.5 * unit, line: Math.max(1, 0.7 * unit) }
|
|
38
|
+
case 'corners':
|
|
39
|
+
return { pad: 0.7 * unit, line: 0 }
|
|
40
|
+
case 'deckle':
|
|
41
|
+
return { pad: 2.6 * unit, line: 0 }
|
|
42
|
+
case 'plate':
|
|
43
|
+
return { pad: 3.2 * unit, line: 0 }
|
|
44
|
+
case 'oval':
|
|
45
|
+
return { pad: 1.5 * unit, line: Math.max(1, 0.22 * unit) }
|
|
46
|
+
case 'botanical':
|
|
47
|
+
return { pad: 5 * unit, line: 0 }
|
|
48
|
+
default:
|
|
49
|
+
return { pad: 0, line: 0 }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A photo printed on a page inside its frame. It fills its parent, or with `fit="contain"` keeps the
|
|
55
|
+
* photo's shape and centres it. `unit` is 1% of the page's width, so frames look the same at any size.
|
|
56
|
+
*/
|
|
57
|
+
export interface PrintedPhotoProps {
|
|
58
|
+
photo: FlipbookPhoto | null
|
|
59
|
+
frame: FrameStyle
|
|
60
|
+
/** 1% of the page width. */
|
|
61
|
+
unit: number
|
|
62
|
+
fit?: 'cover' | 'contain'
|
|
63
|
+
/** False draws a placeholder instead of loading the image. */
|
|
64
|
+
show?: boolean
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function PrintedPhoto({ photo, frame, unit, fit = 'cover', show = true }: PrintedPhotoProps) {
|
|
68
|
+
const look = usePaper()
|
|
69
|
+
const [box, setBox] = useState<{ width: number; height: number } | null>(null)
|
|
70
|
+
const { pad, line } = measure(frame, unit)
|
|
71
|
+
const edge = pad + line
|
|
72
|
+
const ratio = photo?.width && photo.height ? photo.width / photo.height : 4 / 3
|
|
73
|
+
|
|
74
|
+
let width = box?.width ?? 0
|
|
75
|
+
let height = box?.height ?? 0
|
|
76
|
+
|
|
77
|
+
if (box && fit === 'contain') {
|
|
78
|
+
width = Math.min(box.width, (box.height - 2 * edge) * ratio + 2 * edge)
|
|
79
|
+
height = (width - 2 * edge) / ratio + 2 * edge
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const inner: Box = { left: edge, top: edge, width: Math.max(0, width - 2 * edge), height: Math.max(0, height - 2 * edge) }
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<View style={styles.box} onLayout={(event: LayoutChangeEvent) => setBox(event.nativeEvent.layout)}>
|
|
86
|
+
{box && (
|
|
87
|
+
<View style={{ width, height }}>
|
|
88
|
+
<Backing frame={frame} look={look} unit={unit} width={width} height={height} line={line} />
|
|
89
|
+
{frame === 'oval' ? (
|
|
90
|
+
<OvalPhoto photo={show ? photo : null} look={look} rect={inner} width={width} height={height} line={line} />
|
|
91
|
+
) : (
|
|
92
|
+
<View style={[styles.abs, inner]}>
|
|
93
|
+
{photo && show ? (
|
|
94
|
+
<Image source={{ uri: photo.src }} style={StyleSheet.absoluteFill} resizeMode="cover" aria-label={photo.alt ?? undefined} />
|
|
95
|
+
) : (
|
|
96
|
+
<View style={[StyleSheet.absoluteFill, { backgroundColor: ink(look, 0.15) }]} />
|
|
97
|
+
)}
|
|
98
|
+
{frame === 'feather' && <Feather look={look} />}
|
|
99
|
+
</View>
|
|
100
|
+
)}
|
|
101
|
+
<Trim frame={frame} look={look} unit={unit} width={width} height={height} rect={inner} />
|
|
102
|
+
</View>
|
|
103
|
+
)}
|
|
104
|
+
</View>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** What is printed under the photo: rules, mats, tints and bands. */
|
|
109
|
+
function Backing({
|
|
110
|
+
frame,
|
|
111
|
+
look,
|
|
112
|
+
unit,
|
|
113
|
+
width,
|
|
114
|
+
height,
|
|
115
|
+
line,
|
|
116
|
+
}: {
|
|
117
|
+
frame: FrameStyle
|
|
118
|
+
look: PaperLook
|
|
119
|
+
unit: number
|
|
120
|
+
width: number
|
|
121
|
+
height: number
|
|
122
|
+
line: number
|
|
123
|
+
}) {
|
|
124
|
+
const band = BANDS[frame]
|
|
125
|
+
|
|
126
|
+
if (band) return <Band frame={frame} look={look} unit={unit} band={band * unit} width={width} height={height} />
|
|
127
|
+
|
|
128
|
+
switch (frame) {
|
|
129
|
+
case 'hairline':
|
|
130
|
+
return <View style={[StyleSheet.absoluteFill, { borderWidth: line, borderColor: ink(look, 0.7) }]} />
|
|
131
|
+
case 'double': {
|
|
132
|
+
const rule = line / 3
|
|
133
|
+
const color = ink(look, 0.75)
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<>
|
|
137
|
+
<View style={[StyleSheet.absoluteFill, { borderWidth: rule, borderColor: color }]} />
|
|
138
|
+
<View
|
|
139
|
+
style={[
|
|
140
|
+
styles.abs,
|
|
141
|
+
{ top: rule * 2, left: rule * 2, right: rule * 2, bottom: rule * 2, borderWidth: rule, borderColor: color },
|
|
142
|
+
]}
|
|
143
|
+
/>
|
|
144
|
+
</>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
case 'mat':
|
|
148
|
+
return (
|
|
149
|
+
<View style={[StyleSheet.absoluteFill, { backgroundColor: look.frameTint, borderWidth: line, borderColor: ink(look, 0.85) }]} />
|
|
150
|
+
)
|
|
151
|
+
case 'plate': {
|
|
152
|
+
const press = 0.2 * unit
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<View
|
|
156
|
+
style={[
|
|
157
|
+
StyleSheet.absoluteFill,
|
|
158
|
+
{
|
|
159
|
+
backgroundColor: look.plateTint,
|
|
160
|
+
borderTopWidth: press,
|
|
161
|
+
borderLeftWidth: press,
|
|
162
|
+
borderBottomWidth: press,
|
|
163
|
+
borderRightWidth: press,
|
|
164
|
+
borderTopColor: look.plateLow,
|
|
165
|
+
borderLeftColor: look.plateLow,
|
|
166
|
+
borderBottomColor: look.plateHigh,
|
|
167
|
+
borderRightColor: look.plateHigh,
|
|
168
|
+
},
|
|
169
|
+
]}
|
|
170
|
+
/>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
case 'deckle':
|
|
174
|
+
return <Deckle look={look} scallop={1.6 * unit} width={width} height={height} />
|
|
175
|
+
case 'oval':
|
|
176
|
+
return (
|
|
177
|
+
<Svg width={width} height={height} style={styles.abs}>
|
|
178
|
+
<Ellipse
|
|
179
|
+
cx={width / 2}
|
|
180
|
+
cy={height / 2}
|
|
181
|
+
rx={width / 2 - line / 2}
|
|
182
|
+
ry={height / 2 - line / 2}
|
|
183
|
+
stroke={ink(look, 0.7)}
|
|
184
|
+
strokeWidth={line}
|
|
185
|
+
fill="none"
|
|
186
|
+
/>
|
|
187
|
+
</Svg>
|
|
188
|
+
)
|
|
189
|
+
default:
|
|
190
|
+
return null
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** What is printed over or around the photo's edge: keylines, corners and sprigs. */
|
|
195
|
+
function Trim({
|
|
196
|
+
frame,
|
|
197
|
+
look,
|
|
198
|
+
unit,
|
|
199
|
+
width,
|
|
200
|
+
height,
|
|
201
|
+
rect,
|
|
202
|
+
}: {
|
|
203
|
+
frame: FrameStyle
|
|
204
|
+
look: PaperLook
|
|
205
|
+
unit: number
|
|
206
|
+
width: number
|
|
207
|
+
height: number
|
|
208
|
+
rect: Box
|
|
209
|
+
}) {
|
|
210
|
+
if (BANDS[frame]) return <Keyline rect={rect} weight={Math.max(1, 0.2 * unit)} color={ink(look, 0.22)} />
|
|
211
|
+
|
|
212
|
+
switch (frame) {
|
|
213
|
+
case 'mat':
|
|
214
|
+
return <Keyline rect={rect} weight={Math.max(1, 0.25 * unit)} color={ink(look, 0.18)} />
|
|
215
|
+
case 'botanical':
|
|
216
|
+
return (
|
|
217
|
+
<>
|
|
218
|
+
<Keyline rect={rect} weight={Math.max(1, 0.18 * unit)} gap={1.6 * unit} color={ink(look, 0.6)} />
|
|
219
|
+
<Sprig color={ink(look, 0.8)} unit={unit} style={{ top: -1.8 * unit, left: 1.4 * unit }} />
|
|
220
|
+
<Sprig color={ink(look, 0.8)} unit={unit} style={{ bottom: -1.8 * unit, right: 1.4 * unit, transform: [{ rotate: '180deg' }] }} />
|
|
221
|
+
</>
|
|
222
|
+
)
|
|
223
|
+
case 'corners': {
|
|
224
|
+
const size = 5.5 * unit
|
|
225
|
+
|
|
226
|
+
return (
|
|
227
|
+
<Svg width={width} height={height} style={styles.abs}>
|
|
228
|
+
<G fill={ink(look, 0.82)}>
|
|
229
|
+
<Polygon points={`0,0 ${size},0 0,${size}`} />
|
|
230
|
+
<Polygon points={`${width - size},0 ${width},0 ${width},${size}`} />
|
|
231
|
+
<Polygon points={`0,${height - size} ${size},${height} 0,${height}`} />
|
|
232
|
+
<Polygon points={`${width},${height - size} ${width},${height} ${width - size},${height}`} />
|
|
233
|
+
</G>
|
|
234
|
+
</Svg>
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
default:
|
|
238
|
+
return null
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** A printed rule just outside the photo, `gap` away from it. */
|
|
243
|
+
function Keyline({ rect, weight, color, gap = 0 }: { rect: Box; weight: number; color: string; gap?: number }) {
|
|
244
|
+
const out = gap + weight
|
|
245
|
+
|
|
246
|
+
return (
|
|
247
|
+
<View
|
|
248
|
+
style={[
|
|
249
|
+
styles.abs,
|
|
250
|
+
{
|
|
251
|
+
left: rect.left - out,
|
|
252
|
+
top: rect.top - out,
|
|
253
|
+
width: rect.width + out * 2,
|
|
254
|
+
height: rect.height + out * 2,
|
|
255
|
+
borderWidth: weight,
|
|
256
|
+
borderColor: color,
|
|
257
|
+
},
|
|
258
|
+
]}
|
|
259
|
+
/>
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** A tint with a scalloped edge, like an old deckled print. */
|
|
264
|
+
function Deckle({ look, scallop, width, height }: { look: PaperLook; scallop: number; width: number; height: number }) {
|
|
265
|
+
const r = scallop / 2
|
|
266
|
+
const across = Array.from({ length: Math.ceil(width / scallop) }, (_, i) => r + i * scallop)
|
|
267
|
+
const down = Array.from({ length: Math.ceil(height / scallop) }, (_, i) => r + i * scallop)
|
|
268
|
+
|
|
269
|
+
return (
|
|
270
|
+
<Svg width={width} height={height} style={styles.abs}>
|
|
271
|
+
<G opacity={0.11} fill={ink(look)}>
|
|
272
|
+
<Rect x={r} y={r} width={width - scallop} height={height - scallop} />
|
|
273
|
+
{across.map((x) => (
|
|
274
|
+
<G key={`x${x}`}>
|
|
275
|
+
<Circle cx={x} cy={r} r={r} />
|
|
276
|
+
<Circle cx={x} cy={height - r} r={r} />
|
|
277
|
+
</G>
|
|
278
|
+
))}
|
|
279
|
+
{down.map((y) => (
|
|
280
|
+
<G key={`y${y}`}>
|
|
281
|
+
<Circle cx={r} cy={y} r={r} />
|
|
282
|
+
<Circle cx={width - r} cy={y} r={r} />
|
|
283
|
+
</G>
|
|
284
|
+
))}
|
|
285
|
+
</G>
|
|
286
|
+
</Svg>
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** A mat with a patterned band printed around it. */
|
|
291
|
+
function Band({
|
|
292
|
+
frame,
|
|
293
|
+
look,
|
|
294
|
+
unit,
|
|
295
|
+
band,
|
|
296
|
+
width,
|
|
297
|
+
height,
|
|
298
|
+
}: {
|
|
299
|
+
frame: FrameStyle
|
|
300
|
+
look: PaperLook
|
|
301
|
+
unit: number
|
|
302
|
+
band: number
|
|
303
|
+
width: number
|
|
304
|
+
height: number
|
|
305
|
+
}) {
|
|
306
|
+
const id = useId().replace(/[^a-zA-Z0-9]/g, '')
|
|
307
|
+
const ring = `M0 0H${width}V${height}H0Z M${band} ${band}V${height - band}H${width - band}V${band}Z`
|
|
308
|
+
const gilt = Math.max(1, 0.35 * unit)
|
|
309
|
+
|
|
310
|
+
return (
|
|
311
|
+
<>
|
|
312
|
+
<View style={[StyleSheet.absoluteFill, { backgroundColor: look.frameTint }]} />
|
|
313
|
+
<Svg width={width} height={height} style={styles.abs}>
|
|
314
|
+
<Defs>
|
|
315
|
+
<SvgGradient id={`wash${id}`} x1="0" y1="0" x2="1" y2="1">
|
|
316
|
+
<Stop offset="0" stopColor="#f0a993" />
|
|
317
|
+
<Stop offset="0.3" stopColor="#f3d27c" />
|
|
318
|
+
<Stop offset="0.62" stopColor="#9fd3bf" />
|
|
319
|
+
<Stop offset="1" stopColor="#8fb6e2" />
|
|
320
|
+
</SvgGradient>
|
|
321
|
+
<Pattern id={`arcs${id}`} patternUnits="userSpaceOnUse" width={2.4 * unit} height={1.3 * unit}>
|
|
322
|
+
{[
|
|
323
|
+
[1.2, '#7ea7d8'],
|
|
324
|
+
[0.9, '#79c2a2'],
|
|
325
|
+
[0.6, '#f2c95c'],
|
|
326
|
+
[0.3, '#e9967a'],
|
|
327
|
+
].map(([radius, color]) => (
|
|
328
|
+
<Circle key={color} cx={1.2 * unit} cy={1.3 * unit} r={(radius as number) * unit} fill={color as string} />
|
|
329
|
+
))}
|
|
330
|
+
</Pattern>
|
|
331
|
+
{[45, -45].map((angle) => (
|
|
332
|
+
<Pattern
|
|
333
|
+
key={angle}
|
|
334
|
+
id={`hatch${angle}${id}`}
|
|
335
|
+
patternUnits="userSpaceOnUse"
|
|
336
|
+
width={0.8 * unit}
|
|
337
|
+
height={0.8 * unit}
|
|
338
|
+
patternTransform={`rotate(${angle})`}
|
|
339
|
+
>
|
|
340
|
+
<Rect width={0.22 * unit} height={0.8 * unit} fill="rgba(180,138,50,0.75)" />
|
|
341
|
+
</Pattern>
|
|
342
|
+
))}
|
|
343
|
+
<Pattern id={`grain${id}`} patternUnits="userSpaceOnUse" width={1.5 * unit} height={4 * unit} patternTransform="rotate(8)">
|
|
344
|
+
<Rect width={0.12 * unit} height={4 * unit} fill="rgba(110,70,30,0.4)" />
|
|
345
|
+
<Rect x={0.55 * unit} width={0.25 * unit} height={4 * unit} fill="rgba(110,70,30,0.22)" />
|
|
346
|
+
</Pattern>
|
|
347
|
+
</Defs>
|
|
348
|
+
{frame === 'watercolor' && <Path d={ring} fillRule="evenodd" fill={`url(#wash${id})`} opacity={0.7} />}
|
|
349
|
+
{frame === 'arcs' && <Path d={ring} fillRule="evenodd" fill={`url(#arcs${id})`} opacity={0.8} />}
|
|
350
|
+
{frame === 'gilt' && (
|
|
351
|
+
<>
|
|
352
|
+
<Path d={ring} fillRule="evenodd" fill="rgba(214,178,92,0.45)" />
|
|
353
|
+
<Path d={ring} fillRule="evenodd" fill={`url(#hatch45${id})`} />
|
|
354
|
+
<Path d={ring} fillRule="evenodd" fill={`url(#hatch-45${id})`} />
|
|
355
|
+
<Rect x={gilt / 2} y={gilt / 2} width={width - gilt} height={height - gilt} stroke="#b48a32" strokeWidth={gilt} fill="none" />
|
|
356
|
+
</>
|
|
357
|
+
)}
|
|
358
|
+
{frame === 'woodgrain' && (
|
|
359
|
+
<G opacity={0.85}>
|
|
360
|
+
<Path d={ring} fillRule="evenodd" fill="#cfa874" />
|
|
361
|
+
<Path d={ring} fillRule="evenodd" fill={`url(#grain${id})`} />
|
|
362
|
+
</G>
|
|
363
|
+
)}
|
|
364
|
+
</Svg>
|
|
365
|
+
</>
|
|
366
|
+
)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** The photo cut to an oval. */
|
|
370
|
+
function OvalPhoto({
|
|
371
|
+
photo,
|
|
372
|
+
look,
|
|
373
|
+
rect,
|
|
374
|
+
width,
|
|
375
|
+
height,
|
|
376
|
+
}: {
|
|
377
|
+
photo: FlipbookPhoto | null
|
|
378
|
+
look: PaperLook
|
|
379
|
+
rect: Box
|
|
380
|
+
width: number
|
|
381
|
+
height: number
|
|
382
|
+
line: number
|
|
383
|
+
}) {
|
|
384
|
+
const id = `oval${useId().replace(/[^a-zA-Z0-9]/g, '')}`
|
|
385
|
+
|
|
386
|
+
return (
|
|
387
|
+
<Svg width={width} height={height} style={styles.abs}>
|
|
388
|
+
<Defs>
|
|
389
|
+
<ClipPath id={id}>
|
|
390
|
+
<Ellipse cx={width / 2} cy={height / 2} rx={rect.width / 2} ry={rect.height / 2} />
|
|
391
|
+
</ClipPath>
|
|
392
|
+
</Defs>
|
|
393
|
+
{photo ? (
|
|
394
|
+
<SvgImage
|
|
395
|
+
href={{ uri: photo.src }}
|
|
396
|
+
x={rect.left}
|
|
397
|
+
y={rect.top}
|
|
398
|
+
width={rect.width}
|
|
399
|
+
height={rect.height}
|
|
400
|
+
preserveAspectRatio="xMidYMid slice"
|
|
401
|
+
clipPath={`url(#${id})`}
|
|
402
|
+
/>
|
|
403
|
+
) : (
|
|
404
|
+
<Ellipse cx={width / 2} cy={height / 2} rx={rect.width / 2} ry={rect.height / 2} fill={ink(look, 0.15)} />
|
|
405
|
+
)}
|
|
406
|
+
</Svg>
|
|
407
|
+
)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Fades the photo's edges into the paper. */
|
|
411
|
+
function Feather({ look }: { look: PaperLook }) {
|
|
412
|
+
const stops = [
|
|
413
|
+
{ offset: 0, color: look.background },
|
|
414
|
+
{ offset: 0.08, color: look.background, opacity: 0 },
|
|
415
|
+
{ offset: 0.92, color: look.background, opacity: 0 },
|
|
416
|
+
{ offset: 1, color: look.background },
|
|
417
|
+
]
|
|
418
|
+
|
|
419
|
+
return (
|
|
420
|
+
<>
|
|
421
|
+
<Gradient stops={stops} to="right" />
|
|
422
|
+
<Gradient stops={stops} to="bottom" />
|
|
423
|
+
</>
|
|
424
|
+
)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Leaves along a stem, alternating sides and shrinking toward the tip. */
|
|
428
|
+
const LEAVES = [14, 24, 34, 44, 54, 64, 74].map((x, i) => ({ x, angle: i % 2 ? 38 : -38, length: 26 - i * 2 }))
|
|
429
|
+
|
|
430
|
+
/** A sprig of leaves drawn in ink like a pen sketch, its stem along the frame rule. */
|
|
431
|
+
function Sprig({ color, unit, style }: { color: string; unit: number; style: ViewStyle }) {
|
|
432
|
+
return (
|
|
433
|
+
<Svg
|
|
434
|
+
viewBox="0 0 100 40"
|
|
435
|
+
width={26 * unit}
|
|
436
|
+
height={10.4 * unit}
|
|
437
|
+
style={[styles.abs, style]}
|
|
438
|
+
fill="none"
|
|
439
|
+
stroke={color}
|
|
440
|
+
strokeLinecap="round"
|
|
441
|
+
>
|
|
442
|
+
<Path d="M4 20 Q 50 17, 92 20" strokeWidth={1.2} />
|
|
443
|
+
{LEAVES.map(({ x, angle, length }) => (
|
|
444
|
+
<G key={x} transform={`translate(${x} 20) rotate(${angle}) scale(${length / 20})`}>
|
|
445
|
+
<Path d="M0 0 C 5 -5, 14 -5, 20 0 C 14 5, 5 5, 0 0 Z" strokeWidth={1} />
|
|
446
|
+
<Path d="M2 0 L 16 0" strokeWidth={0.6} />
|
|
447
|
+
</G>
|
|
448
|
+
))}
|
|
449
|
+
</Svg>
|
|
450
|
+
)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const styles = StyleSheet.create({
|
|
454
|
+
box: { position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, alignItems: 'center', justifyContent: 'center' },
|
|
455
|
+
abs: { position: 'absolute' },
|
|
456
|
+
})
|
package/src/geometry.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { Affine, Fold, type ActiveTurn, type TurnFrame, type Vec } from '@toneflix/flipbook-core'
|
|
2
|
+
import type { ViewStyle } from 'react-native'
|
|
3
|
+
|
|
4
|
+
export type Role = 'right' | 'left' | 'turning' | 'flap' | 'spare'
|
|
5
|
+
|
|
6
|
+
/** A strip of light or shadow laid along the fold, inside one face. */
|
|
7
|
+
export interface Band {
|
|
8
|
+
name: 'under' | 'bend' | 'curl'
|
|
9
|
+
style: ViewStyle
|
|
10
|
+
opacity: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Where one face sits: a clip window, a frame undoing the window's rotation, and the face itself. */
|
|
14
|
+
export interface FacePlacement {
|
|
15
|
+
clip: ViewStyle
|
|
16
|
+
frame: ViewStyle
|
|
17
|
+
face: ViewStyle
|
|
18
|
+
bands: Band[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Band widths as shares of the page width. */
|
|
22
|
+
const BAND_WIDTH = { under: 0.3, bend: 0.1, curl: 1 }
|
|
23
|
+
|
|
24
|
+
const styleOf = (transform: Affine, width: number, height: number): ViewStyle => {
|
|
25
|
+
const { left, top, angle, mirror } = transform.place(width, height)
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
position: 'absolute',
|
|
29
|
+
left,
|
|
30
|
+
top,
|
|
31
|
+
width,
|
|
32
|
+
height,
|
|
33
|
+
transform: mirror ? [{ rotate: `${angle}rad` }, { scaleX: -1 }] : [{ rotate: `${angle}rad` }],
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Lays out the faces of a book for React Native, which can't clip to a polygon. Coordinates are
|
|
39
|
+
* page pixels from the top of the spine. A folded page is clipped by a large window rotated to
|
|
40
|
+
* line up with the fold, inside which the faces are placed back in spine coordinates, so the
|
|
41
|
+
* same three nested views serve every face whether it's turning or lying still.
|
|
42
|
+
*/
|
|
43
|
+
export class FoldLayout {
|
|
44
|
+
private readonly side: number
|
|
45
|
+
private readonly rest: Affine
|
|
46
|
+
private readonly window: Affine
|
|
47
|
+
private readonly fold: Fold | null
|
|
48
|
+
private readonly dir: 1 | -1
|
|
49
|
+
private readonly reflection: Affine
|
|
50
|
+
|
|
51
|
+
constructor(
|
|
52
|
+
readonly width: number,
|
|
53
|
+
readonly height: number,
|
|
54
|
+
readonly turn: ActiveTurn | null,
|
|
55
|
+
readonly frame: TurnFrame | null,
|
|
56
|
+
readonly shading = 1,
|
|
57
|
+
) {
|
|
58
|
+
this.side = 4 * Math.max(width, height)
|
|
59
|
+
this.rest = Affine.translate(-2 * width, -height)
|
|
60
|
+
this.dir = turn?.dir ?? 1
|
|
61
|
+
this.fold = turn && frame && !turn.stiff ? new Fold(width, height, frame.origin, frame.point) : null
|
|
62
|
+
this.window = this.rest
|
|
63
|
+
this.reflection = new Affine()
|
|
64
|
+
|
|
65
|
+
if (!this.fold) return
|
|
66
|
+
|
|
67
|
+
const { center, normal } = this.fold
|
|
68
|
+
const c = { x: this.dir * center.x, y: center.y }
|
|
69
|
+
const n = { x: this.dir * normal.x, y: normal.y }
|
|
70
|
+
|
|
71
|
+
this.window = Affine.translate(c.x, c.y)
|
|
72
|
+
.after(Affine.rotate(Math.atan2(n.y, n.x)))
|
|
73
|
+
.after(Affine.translate(-this.side, -this.side / 2))
|
|
74
|
+
this.reflection = Affine.reflect(c, n)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Spine coordinates of the fold's centre and its normal toward the folded part. */
|
|
78
|
+
private get line(): { c: Vec; n: Vec } {
|
|
79
|
+
const { center, normal } = this.fold!
|
|
80
|
+
|
|
81
|
+
return { c: { x: this.dir * center.x, y: center.y }, n: { x: this.dir * normal.x, y: normal.y } }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Places a face given its role, which side of the leaf it is, and whether it lies on the left. */
|
|
85
|
+
place(role: Role, side: 'front' | 'back', onLeft: boolean): FacePlacement {
|
|
86
|
+
const { width, height, fold } = this
|
|
87
|
+
const turning = role === 'turning' || role === 'flap'
|
|
88
|
+
const clipped = !!fold && turning
|
|
89
|
+
const window = clipped ? this.window : this.rest
|
|
90
|
+
const windowSize = clipped ? { w: this.side, h: this.side } : { w: 4 * width, h: 3 * height }
|
|
91
|
+
const toFrame = window.invert().after(Affine.translate(-width, 0))
|
|
92
|
+
let face = Affine.translate(onLeft ? -width : 0, 0)
|
|
93
|
+
|
|
94
|
+
if (fold && role === 'flap') {
|
|
95
|
+
const leaf = side === 'back' ? new Affine(-1, 0, 0, 1, width, 0) : new Affine()
|
|
96
|
+
|
|
97
|
+
face = this.reflection.after(Affine.scale(this.dir, 1)).after(leaf)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const placement: FacePlacement = {
|
|
101
|
+
clip: { ...styleOf(window, windowSize.w, windowSize.h), overflow: clipped ? 'hidden' : 'visible' },
|
|
102
|
+
frame: styleOf(toFrame, 2 * width, height),
|
|
103
|
+
face: { ...styleOf(Affine.translate(width, 0).after(face), width, height), overflow: 'hidden' },
|
|
104
|
+
bands: [],
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (fold) placement.bands = this.bands(role, face)
|
|
108
|
+
if (this.turn?.stiff && turning) placement.face = this.hinge(side, placement.face)
|
|
109
|
+
|
|
110
|
+
return placement
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Light and shadow along the fold that fall on this face. */
|
|
114
|
+
private bands(role: Role, face: Affine): Band[] {
|
|
115
|
+
const strength = this.fold!.shade * this.shading
|
|
116
|
+
const band = (name: Band['name'], into: 1 | -1): Band => {
|
|
117
|
+
const { c, n } = this.line
|
|
118
|
+
const m = { x: into * n.x, y: into * n.y }
|
|
119
|
+
const d = { x: -n.y, y: n.x }
|
|
120
|
+
const width = this.width * BAND_WIDTH[name]
|
|
121
|
+
const height = this.side
|
|
122
|
+
const strip = new Affine(m.x, m.y, d.x, d.y, c.x - (height / 2) * d.x, c.y - (height / 2) * d.y)
|
|
123
|
+
|
|
124
|
+
return { name, style: styleOf(face.invert().after(strip), width, height), opacity: strength }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (role === 'turning') return [band('bend', -1)]
|
|
128
|
+
if (role === 'flap') return [band('curl', -1)]
|
|
129
|
+
|
|
130
|
+
const revealed = this.turn && (this.dir === 1 ? role === 'right' : role === 'left')
|
|
131
|
+
|
|
132
|
+
return revealed ? [band('under', 1)] : []
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A stiff cover swings on the spine instead of folding. */
|
|
136
|
+
private hinge(side: 'front' | 'back', style: ViewStyle): ViewStyle {
|
|
137
|
+
const { width, frame } = this
|
|
138
|
+
const angle = frame ? Fold.hinge(frame.origin, frame.point, this.dir) : 0
|
|
139
|
+
const swing = [{ translateX: -width / 2 }, { perspective: Math.max(1800, width * 5) }, { rotateY: `${angle}deg` }]
|
|
140
|
+
const transform =
|
|
141
|
+
side === 'front'
|
|
142
|
+
? [...swing, { translateX: width / 2 }]
|
|
143
|
+
: [...swing, { translateX: width }, { rotateY: '180deg' }, { translateX: width / 2 }]
|
|
144
|
+
|
|
145
|
+
return { ...style, transform, backfaceVisibility: 'hidden' } as ViewStyle
|
|
146
|
+
}
|
|
147
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export * from '@toneflix/flipbook-core'
|
|
2
|
+
export { Flipbook, type FlipbookProps, type FlipbookHandle, type FlipbookPlace } from './Flipbook'
|
|
3
|
+
export {
|
|
4
|
+
PageView,
|
|
5
|
+
Paper,
|
|
6
|
+
CoverView,
|
|
7
|
+
BackCoverView,
|
|
8
|
+
TitlePageView,
|
|
9
|
+
ColophonView,
|
|
10
|
+
BlankPageView,
|
|
11
|
+
PAGE_RATIO,
|
|
12
|
+
type PageViewProps,
|
|
13
|
+
type CoverViewProps,
|
|
14
|
+
} from './pages'
|
|
15
|
+
export { PrintedPhoto, type PrintedPhotoProps } from './PrintedPhoto'
|
|
16
|
+
export { BookLookProvider, useBookLook, usePaper, useSerif, ink, PAPERS, type BookLook, type BookFonts, type PaperLook } from './look'
|
|
17
|
+
export { Gradient, type GradientStop } from './Gradient'
|
|
18
|
+
export { FoldLayout } from './geometry'
|
|
19
|
+
export { lockLandscape } from './orientation'
|