@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.
@@ -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
+ })
@@ -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'