@toneflix/flipbook-native 0.2.0 → 0.2.2

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @toneflix/flipbook-native
2
2
 
3
- Turn any set of photos into an album that bends, settles and feels like paper, in React Native and Expo, with page turns drawn by native views. A page folds exactly where a finger bends it, photos sink into the paper's grain, and phones open the book sideways across two pages.
3
+ An expressive flipbook for creating personalised and interactive reading and photo viewing experiences, for React Native and Expo.
4
4
 
5
5
  ```bash
6
6
  npx expo install @toneflix/flipbook-native react-native-svg expo-screen-orientation
@@ -21,4 +21,4 @@ export function Book({ book }) {
21
21
  }
22
22
  ```
23
23
 
24
- See the [documentation](https://toneflix.github.io/flipbook/guide/react-native).
24
+ See the [documentation](https://toneflix.github.io/flipbook/frameworks/react-native).
package/lib/Cover.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { type FlipbookBook } from '@toneflix/flipbook-core';
2
+ export interface CoverViewProps {
3
+ book: Pick<FlipbookBook, 'title' | 'subtitle' | 'theme' | 'cover' | 'kicker' | 'coverDesign' | 'coverText'>;
4
+ width: number;
5
+ showImages?: boolean;
6
+ }
7
+ /** The front cover, laid out by the book's `coverDesign`. */
8
+ export declare function CoverView({ book, width, showImages }: CoverViewProps): import("react").JSX.Element;
9
+ /** The back cover: the same board, with the imprint. */
10
+ export declare function BackCoverView({ book, width }: {
11
+ book: Pick<FlipbookBook, 'theme' | 'imprint' | 'coverDesign'>;
12
+ width: number;
13
+ }): import("react").JSX.Element;
package/lib/Cover.js ADDED
@@ -0,0 +1,122 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useId } from 'react';
3
+ import { Image, StyleSheet, Text, View } from 'react-native';
4
+ import Svg, { ClipPath, Defs, Ellipse, Image as SvgImage, Path, RadialGradient, Rect, Stop } from 'react-native-svg';
5
+ import { COVER_THEMES, DECO, FLEURON, coverHasText } from '@toneflix/flipbook-core';
6
+ import { Gradient } from './Gradient';
7
+ import { PAPERS, useSerif } from './look';
8
+ import { Ornament, PAGE_RATIO } from './pages';
9
+ const CORNERS = ['tl', 'tr', 'bl', 'br'];
10
+ const MIRROR = {
11
+ tl: undefined,
12
+ tr: [{ scaleX: -1 }],
13
+ bl: [{ scaleY: -1 }],
14
+ br: [{ scaleX: -1 }, { scaleY: -1 }],
15
+ };
16
+ function Art({ art, color, style }) {
17
+ return (_jsxs(Svg, { viewBox: art.viewBox, preserveAspectRatio: "none", style: style, pointerEvents: "none", children: [art.strokes.map(({ d, width, opacity = 1 }) => (_jsx(Path, { d: d, fill: "none", stroke: color, strokeWidth: width, strokeOpacity: opacity }, d))), art.fills.map((d) => (_jsx(Path, { d: d, fill: color }, d)))] }));
18
+ }
19
+ /** Gilt rules and a flourish in each corner. */
20
+ function GiltFrame({ unit, color }) {
21
+ const size = 18 * unit;
22
+ const at = 3.6 * unit;
23
+ const place = {
24
+ tl: { top: at, left: at },
25
+ tr: { top: at, right: at },
26
+ bl: { bottom: at, left: at },
27
+ br: { bottom: at, right: at },
28
+ };
29
+ return (_jsxs(_Fragment, { children: [_jsx(View, { pointerEvents: "none", style: [styles.fill, { margin: 5 * unit, borderWidth: 0.35 * unit, borderColor: color, opacity: 0.85 }] }), _jsx(View, { pointerEvents: "none", style: [styles.fill, { margin: 6.6 * unit, borderWidth: 0.15 * unit, borderColor: color, opacity: 0.85 }] }), CORNERS.map((corner) => (_jsx(Art, { art: FLEURON, color: color, style: { position: 'absolute', width: size, height: size, ...place[corner], transform: MIRROR[corner] } }, corner)))] }));
30
+ }
31
+ /** A photo clipped to an oval or an arch, ruled in the ink. */
32
+ function ShapedPhoto({ photo, shape, width, height, unit, color, show, }) {
33
+ const id = `c${useId().replace(/[^a-zA-Z0-9]/g, '')}`;
34
+ const r = width / 2;
35
+ const arch = `M0 ${height}V${r}A${r} ${r} 0 0 1 ${width} ${r}V${height}Z`;
36
+ const outline = shape === 'oval' ? _jsx(Ellipse, { cx: r, cy: height / 2, rx: r - 0.3 * unit, ry: height / 2 - 0.3 * unit }) : _jsx(Path, { d: arch });
37
+ return (_jsxs(Svg, { width: width, height: height, children: [_jsx(Defs, { children: _jsx(ClipPath, { id: id, children: outline }) }), show ? (_jsx(SvgImage, { href: { uri: photo.src }, width: width, height: height, preserveAspectRatio: "xMidYMid slice", clipPath: `url(#${id})` })) : (_jsx(Rect, { width: width, height: height, fill: color, fillOpacity: 0.15, clipPath: `url(#${id})` })), shape === 'oval' ? (_jsx(Ellipse, { cx: r, cy: height / 2, rx: r - 0.3 * unit, ry: height / 2 - 0.3 * unit, fill: "none", stroke: color, strokeWidth: 0.6 * unit })) : (_jsx(Path, { d: arch, fill: "none", stroke: color, strokeWidth: 1 * unit }))] }));
38
+ }
39
+ const Photo = ({ photo, show }) => show ? _jsx(Image, { source: { uri: photo.src }, style: StyleSheet.absoluteFill, resizeMode: "cover", "aria-label": photo.alt ?? undefined }) : null;
40
+ /** Cover board: cloth, light, the hinge shadow along the spine and a grain finish. */
41
+ function Board({ book, width, hinge, children }) {
42
+ const theme = COVER_THEMES[book.theme ?? 'classic'];
43
+ const id = useId().replace(/[^a-zA-Z0-9]/g, '');
44
+ const shade = (a, offset) => ({ offset, color: '#000', opacity: a });
45
+ return (_jsxs(View, { style: [styles.board, { width, height: width / PAGE_RATIO, backgroundColor: theme.cloth }], children: [children, _jsxs(Svg, { style: StyleSheet.absoluteFill, width: "100%", height: "100%", pointerEvents: "none", children: [_jsxs(Defs, { children: [_jsxs(RadialGradient, { id: `sheen${id}`, cx: "30%", cy: "20%", rx: "45%", ry: "45%", children: [_jsx(Stop, { offset: "0", stopColor: "#fff", stopOpacity: 0.1 }), _jsx(Stop, { offset: "1", stopColor: "#fff", stopOpacity: 0 })] }), _jsxs(RadialGradient, { id: `edge${id}`, cx: "50%", cy: "50%", rx: "70%", ry: "70%", children: [_jsx(Stop, { offset: "0.6", stopColor: "#000", stopOpacity: 0 }), _jsx(Stop, { offset: "1", stopColor: "#000", stopOpacity: 0.25 })] })] }), _jsx(Rect, { width: "100%", height: "100%", fill: `url(#sheen${id})` }), _jsx(Rect, { width: "100%", height: "100%", fill: `url(#edge${id})` })] }), _jsx(Image, { source: PAPERS.ivory.grain, resizeMode: "repeat", pointerEvents: "none", style: [StyleSheet.absoluteFill, { opacity: 0.25, mixBlendMode: 'soft-light' }] }), _jsx(Gradient, { to: hinge === 'left' ? 'right' : 'left', style: [styles.hinge, hinge === 'left' ? styles.left : styles.right, { width: width * 0.04 }], stops: [shade(0.35, 0), shade(0.1, 0.5), shade(0, 1)] })] }));
46
+ }
47
+ /** The front cover, laid out by the book's `coverDesign`. */
48
+ export function CoverView({ book, width, showImages = true }) {
49
+ const color = COVER_THEMES[book.theme ?? 'classic'].ink;
50
+ const serif = useSerif();
51
+ const unit = width / 100;
52
+ const text = coverHasText(book);
53
+ const stamped = {
54
+ textShadowColor: 'rgba(0,0,0,0.35)',
55
+ textShadowOffset: { width: 0, height: 0.15 * unit },
56
+ textShadowRadius: 0,
57
+ };
58
+ const kicker = (ink = color) => book.kicker ? (_jsx(Text, { style: [styles.kicker, { fontSize: 2.6 * unit, letterSpacing: 0.78 * unit, color: ink }], children: book.kicker.toUpperCase() })) : null;
59
+ const title = (size, ink = color, extra) => (_jsx(Text, { style: [
60
+ serif.upright,
61
+ styles.center,
62
+ stamped,
63
+ { marginTop: 4 * unit, fontSize: size * unit, lineHeight: size * 1.08 * unit, fontWeight: '500', color: ink },
64
+ extra,
65
+ ], children: book.title }));
66
+ const subtitle = (size = 4, ink = color) => book.subtitle ? (_jsx(Text, { style: [
67
+ serif.italic,
68
+ styles.center,
69
+ { marginTop: 3 * unit, fontSize: size * unit, lineHeight: size * 1.35 * unit, color: ink, opacity: 0.85 },
70
+ ], children: book.subtitle })) : null;
71
+ const designs = {
72
+ classic: () => (_jsxs(View, { style: [styles.panel, { margin: 6 * unit, paddingVertical: 8 * unit, paddingHorizontal: 6 * unit, borderColor: color }], children: [text && kicker(), text && title(9), book.cover ? (_jsx(View, { style: [styles.coverPhoto, { marginTop: 7 * unit, borderWidth: 0.8 * unit, borderColor: color }], children: _jsx(Photo, { photo: book.cover, show: showImages }) })) : (text && subtitle())] })),
73
+ gilt: () => (_jsxs(_Fragment, { children: [_jsx(GiltFrame, { unit: unit, color: color }), _jsxs(View, { style: [styles.stack, { top: 14 * unit, bottom: 14 * unit, left: 12 * unit, right: 12 * unit }], children: [text && kicker(), text && title(8), book.cover && (_jsx(View, { style: { marginTop: 6 * unit }, children: _jsx(ShapedPhoto, { photo: book.cover, shape: "oval", width: 52 * unit, height: 64 * unit, unit: unit, color: color, show: showImages }) })), text && subtitle()] })] })),
74
+ window: () => (_jsxs(_Fragment, { children: [_jsxs(View, { style: [styles.window, { top: 10 * unit, left: 12 * unit, right: 12 * unit, height: 76 * unit, borderRadius: 0.6 * unit }], children: [book.cover ? (_jsx(Photo, { photo: book.cover, show: showImages })) : (_jsx(View, { style: [styles.fill, styles.centered], children: _jsx(Ornament, { size: 16 * unit, color: color }) })), _jsx(Gradient, { to: "bottom", style: [styles.edge, { top: 0, height: 4 * unit }], stops: [
75
+ { offset: 0, color: '#000', opacity: 0.55 },
76
+ { offset: 1, color: '#000', opacity: 0 },
77
+ ] }), _jsx(Gradient, { to: "right", style: [styles.edgeV, { left: 0, width: 2 * unit }], stops: [
78
+ { offset: 0, color: '#000', opacity: 0.3 },
79
+ { offset: 1, color: '#000', opacity: 0 },
80
+ ] }), _jsx(Gradient, { to: "left", style: [styles.edgeV, { right: 0, width: 2 * unit }], stops: [
81
+ { offset: 0, color: '#000', opacity: 0.25 },
82
+ { offset: 1, color: '#000', opacity: 0 },
83
+ ] })] }), text && (_jsxs(View, { style: [styles.stack, { top: 90 * unit, bottom: 8 * unit, left: 10 * unit, right: 10 * unit }], children: [kicker(), title(7.5, color, { textShadowColor: 'rgba(0,0,0,0.45)', textShadowOffset: { width: 0, height: -0.15 * unit } }), subtitle(3.4)] }))] })),
84
+ label: () => (_jsxs(_Fragment, { children: [book.cover && (_jsxs(View, { style: [styles.print, { top: 11 * unit, left: 16 * unit, right: 16 * unit, height: 62 * unit, padding: 2.4 * unit }], children: [_jsx(View, { style: [styles.flex, styles.clip], children: _jsx(Photo, { photo: book.cover, show: showImages }) }), _jsx(Svg, { style: StyleSheet.absoluteFill, viewBox: "0 0 100 100", preserveAspectRatio: "none", pointerEvents: "none", children: _jsx(Path, { d: "M0 0H9L0 9Z M100 0V9L91 0Z M0 100V91L9 100Z M100 100H91L100 91Z", fill: "#1f1b18" }) })] })), text && (_jsxs(View, { style: [styles.label, { top: (book.cover ? 84 : 44) * unit, left: 18 * unit, right: 18 * unit, padding: 4 * unit }], children: [_jsx(View, { pointerEvents: "none", style: [styles.fill, { margin: 1.2 * unit, borderWidth: 0.2 * unit, borderColor: 'rgba(60,45,30,0.5)' }] }), kicker('#3a2f25'), title(6.4, '#3a2f25', { marginTop: 1 * unit, textShadowColor: 'transparent' }), subtitle(3, '#3a2f25')] }))] })),
85
+ deco: () => (_jsxs(_Fragment, { children: [_jsx(Art, { art: DECO, color: color, style: StyleSheet.absoluteFill }), _jsxs(View, { style: [styles.stack, { top: 17 * unit, bottom: 10 * unit, left: 14 * unit, right: 14 * unit, justifyContent: 'flex-start' }], children: [text && kicker(), book.cover && (_jsx(View, { style: { marginTop: 4 * unit }, children: _jsx(ShapedPhoto, { photo: book.cover, shape: "arch", width: 56 * unit, height: 62 * unit, unit: unit, color: color, show: showImages }) })), text && title(6.6, color, { marginTop: 7 * unit, fontWeight: '400', letterSpacing: 0.8 * unit, textTransform: 'uppercase' }), text && subtitle(3.4)] })] })),
86
+ image: () => (_jsxs(_Fragment, { children: [book.cover && _jsx(Photo, { photo: book.cover, show: showImages }), book.cover && (_jsx(View, { pointerEvents: "none", style: [StyleSheet.absoluteFill, { backgroundColor: color, opacity: 0.18, mixBlendMode: 'soft-light' }] })), text &&
87
+ (book.cover ? (_jsxs(View, { style: [styles.caption, { paddingTop: 26 * unit, paddingHorizontal: 10 * unit, paddingBottom: 10 * unit }], children: [_jsx(Gradient, { to: "top", stops: [
88
+ { offset: 0, color: '#000', opacity: 0.65 },
89
+ { offset: 0.55, color: '#000', opacity: 0.25 },
90
+ { offset: 1, color: '#000', opacity: 0 },
91
+ ] }), kicker('#fff'), title(9, '#fff', { textShadowColor: 'rgba(0,0,0,0.5)', textShadowRadius: 1.2 * unit }), subtitle(4, '#fff')] })) : (_jsxs(View, { style: [styles.fill, styles.centered, { padding: 10 * unit }], children: [kicker(), title(9), subtitle()] })))] })),
92
+ };
93
+ return (_jsx(Board, { book: book, width: width, hinge: "left", children: designs[book.coverDesign ?? 'classic']() }));
94
+ }
95
+ /** The back cover: the same board, with the imprint. */
96
+ export function BackCoverView({ book, width }) {
97
+ const color = COVER_THEMES[book.theme ?? 'classic'].ink;
98
+ const unit = width / 100;
99
+ return (_jsxs(Board, { book: book, width: width, hinge: "right", children: [book.coverDesign === 'gilt' && _jsx(GiltFrame, { unit: unit, color: color }), _jsxs(View, { style: [styles.imprint, { bottom: 10 * unit }], children: [_jsx(Ornament, { size: 9 * unit, color: color }), book.imprint ? (_jsx(Text, { style: [styles.kicker, { marginTop: 2 * unit, fontSize: 2.4 * unit, letterSpacing: 0.72 * unit, color, opacity: 0.6 }], children: book.imprint.toUpperCase() })) : null] })] }));
100
+ }
101
+ const styles = StyleSheet.create({
102
+ board: { overflow: 'hidden' },
103
+ fill: { position: 'absolute', top: 0, right: 0, bottom: 0, left: 0 },
104
+ flex: { flex: 1 },
105
+ clip: { overflow: 'hidden' },
106
+ centered: { alignItems: 'center', justifyContent: 'center' },
107
+ center: { textAlign: 'center' },
108
+ hinge: { position: 'absolute', top: 0, bottom: 0 },
109
+ left: { left: 0 },
110
+ right: { right: 0 },
111
+ panel: { flex: 1, alignItems: 'center', borderWidth: StyleSheet.hairlineWidth },
112
+ kicker: { textAlign: 'center', opacity: 0.7 },
113
+ coverPhoto: { width: '100%', flex: 1, maxHeight: '62%', overflow: 'hidden' },
114
+ stack: { position: 'absolute', alignItems: 'center', justifyContent: 'center' },
115
+ window: { position: 'absolute', overflow: 'hidden', backgroundColor: 'rgba(0,0,0,0.12)' },
116
+ edge: { position: 'absolute', left: 0, right: 0 },
117
+ edgeV: { position: 'absolute', top: 0, bottom: 0 },
118
+ print: { position: 'absolute', backgroundColor: '#f7f2e7', transform: [{ rotate: '-2.5deg' }], boxShadow: '0 3px 8px rgba(0,0,0,0.4)' },
119
+ label: { position: 'absolute', alignItems: 'center', backgroundColor: '#f4eddc', boxShadow: '0 1px 4px rgba(0,0,0,0.3)' },
120
+ caption: { position: 'absolute', left: 0, right: 0, bottom: 0, alignItems: 'center' },
121
+ imprint: { position: 'absolute', left: 0, right: 0, alignItems: 'center' },
122
+ });
package/lib/Flipbook.js CHANGED
@@ -6,7 +6,8 @@ import { BookLayout, COVER_THEMES, DEFAULT_LABELS, FlipbookReader, } from '@tone
6
6
  import { FoldLayout } from './geometry';
7
7
  import { Gradient } from './Gradient';
8
8
  import { BookLookProvider } from './look';
9
- import { BackCoverView, BlankPageView, ColophonView, CoverView, PAGE_RATIO, PageView, TitlePageView } from './pages';
9
+ import { BackCoverView, CoverView } from './Cover';
10
+ import { BlankPageView, ColophonView, PAGE_RATIO, PageView, TitlePageView } from './pages';
10
11
  import { lockLandscape } from './orientation';
11
12
  /** One face's content. Memoised so turning a page only moves its wrappers. */
12
13
  const FaceContent = memo(function FaceContent({ face, book, width, side, }) {
package/lib/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from '@toneflix/flipbook-core';
2
2
  export { Flipbook, type FlipbookProps, type FlipbookHandle, type FlipbookPlace } from './Flipbook';
3
- export { PageView, Paper, CoverView, BackCoverView, TitlePageView, ColophonView, BlankPageView, PAGE_RATIO, type PageViewProps, type CoverViewProps, } from './pages';
3
+ export { PageView, Paper, TitlePageView, ColophonView, BlankPageView, PAGE_RATIO, type PageViewProps } from './pages';
4
+ export { CoverView, BackCoverView, type CoverViewProps } from './Cover';
4
5
  export { PrintedPhoto, type PrintedPhotoProps } from './PrintedPhoto';
5
6
  export { BookLookProvider, useBookLook, usePaper, useSerif, ink, PAPERS, type BookLook, type BookFonts, type PaperLook } from './look';
6
7
  export { Gradient, type GradientStop } from './Gradient';
package/lib/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from '@toneflix/flipbook-core';
2
2
  export { Flipbook } from './Flipbook';
3
- export { PageView, Paper, CoverView, BackCoverView, TitlePageView, ColophonView, BlankPageView, PAGE_RATIO, } from './pages';
3
+ export { PageView, Paper, TitlePageView, ColophonView, BlankPageView, PAGE_RATIO } from './pages';
4
+ export { CoverView, BackCoverView } from './Cover';
4
5
  export { PrintedPhoto } from './PrintedPhoto';
5
6
  export { BookLookProvider, useBookLook, usePaper, useSerif, ink, PAPERS } from './look';
6
7
  export { Gradient } from './Gradient';
package/lib/look.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { createContext, useContext } from 'react';
3
3
  import { Platform } from 'react-native';
4
- const fine = { texture: require('../assets/textures/paper-fine@2x.webp'), grain: require('../assets/textures/grain-fine@2x.webp') };
4
+ const fine = { texture: require('../assets/textures/paper-fine.webp'), grain: require('../assets/textures/grain-fine.webp') };
5
5
  const ivory = {
6
6
  background: '#fdf9f0',
7
7
  ink: [61, 55, 47],
@@ -25,9 +25,9 @@ export const PAPERS = {
25
25
  plateTint: 'rgba(90,70,40,0.03)',
26
26
  plateLow: 'rgba(80,50,20,0.16)',
27
27
  plateHigh: 'rgba(255,245,225,0.35)',
28
- texture: require('../assets/textures/paper-kraft@2x.webp'),
28
+ texture: require('../assets/textures/paper-kraft.webp'),
29
29
  textureOpacity: 0.45,
30
- grain: require('../assets/textures/grain-kraft@2x.webp'),
30
+ grain: require('../assets/textures/grain-kraft.webp'),
31
31
  grainOpacity: 0.35,
32
32
  light: 'rgba(255,255,255,0.18)',
33
33
  vignette: 'rgba(80,50,20,0.12)',
@@ -39,9 +39,9 @@ export const PAPERS = {
39
39
  plateTint: 'rgba(0,0,0,0.08)',
40
40
  plateLow: 'rgba(0,0,0,0.35)',
41
41
  plateHigh: 'rgba(255,255,255,0.07)',
42
- texture: require('../assets/textures/paper-charcoal@2x.webp'),
42
+ texture: require('../assets/textures/paper-charcoal.webp'),
43
43
  textureOpacity: 0.25,
44
- grain: require('../assets/textures/grain-charcoal@2x.webp'),
44
+ grain: require('../assets/textures/grain-charcoal.webp'),
45
45
  grainOpacity: 0.22,
46
46
  light: 'rgba(255,255,255,0.05)',
47
47
  vignette: 'rgba(0,0,0,0.25)',
package/lib/pages.d.ts CHANGED
@@ -29,17 +29,6 @@ export declare function Paper({ width, side, children, style }: {
29
29
  children?: ReactNode;
30
30
  style?: ViewStyle;
31
31
  }): import("react").JSX.Element;
32
- export interface CoverViewProps {
33
- book: Pick<FlipbookBook, 'title' | 'subtitle' | 'theme' | 'cover' | 'kicker'>;
34
- width: number;
35
- showImages?: boolean;
36
- }
37
- /** The front cover: cloth with the title and an inset photo. */
38
- export declare function CoverView({ book, width, showImages }: CoverViewProps): import("react").JSX.Element;
39
- export declare function BackCoverView({ book, width }: {
40
- book: Pick<FlipbookBook, 'theme' | 'imprint'>;
41
- width: number;
42
- }): import("react").JSX.Element;
43
32
  /** Inside the front cover: a title page. */
44
33
  export declare function TitlePageView({ book, width, side, }: {
45
34
  book: Pick<FlipbookBook, 'title' | 'subtitle' | 'author' | 'year'>;
@@ -56,4 +45,8 @@ export declare function BlankPageView({ width, side }: {
56
45
  width: number;
57
46
  side?: Side;
58
47
  }): import("react").JSX.Element;
48
+ export declare function Ornament({ size, color }: {
49
+ size: number;
50
+ color: string;
51
+ }): import("react").JSX.Element;
59
52
  export {};
package/lib/pages.js CHANGED
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { useId } from 'react';
3
3
  import { Image, Pressable, StyleSheet, Text, View } from 'react-native';
4
4
  import Svg, { Circle, Defs, Path, RadialGradient, Rect, Stop } from 'react-native-svg';
5
- import { COVER_THEMES, PLACED_LAYOUTS, photoFrame } from '@toneflix/flipbook-core';
5
+ import { PLACED_LAYOUTS, photoFrame } from '@toneflix/flipbook-core';
6
6
  import { Gradient } from './Gradient';
7
7
  import { ink, useBookLook, usePaper, useSerif } from './look';
8
8
  import { PrintedPhoto } from './PrintedPhoto';
@@ -67,29 +67,6 @@ function Gutter({ side }) {
67
67
  const sheen = (a, offset) => ({ offset, color: 'rgb(255,250,240)', opacity: a });
68
68
  return (_jsxs(_Fragment, { children: [_jsx(Gradient, { to: to, 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)] }), _jsx(Gradient, { to: to, stops: [sheen(0, 0.06), sheen(0.07, 0.13), sheen(0.02, 0.26), sheen(0, 0.4)] })] }));
69
69
  }
70
- /** Cloth for a cover, with the hinge shadow along the spine. */
71
- function Cloth({ book, width, hinge, children }) {
72
- const theme = COVER_THEMES[book.theme ?? 'classic'];
73
- const id = useId().replace(/[^a-zA-Z0-9]/g, '');
74
- const shade = (a, offset) => ({ offset, color: '#000', opacity: a });
75
- return (_jsxs(View, { style: [styles.page, { width, height: width / PAGE_RATIO, backgroundColor: theme.cloth }], children: [_jsxs(Svg, { style: StyleSheet.absoluteFill, width: "100%", height: "100%", pointerEvents: "none", children: [_jsx(Defs, { children: _jsxs(RadialGradient, { id: `sheen${id}`, cx: "30%", cy: "20%", rx: "45%", ry: "45%", children: [_jsx(Stop, { offset: "0", stopColor: "#fff", stopOpacity: 0.08 }), _jsx(Stop, { offset: "1", stopColor: "#fff", stopOpacity: 0 })] }) }), _jsx(Rect, { width: "100%", height: "100%", fill: `url(#sheen${id})` })] }), _jsx(Gradient, { to: hinge === 'left' ? 'right' : 'left', style: [styles.hinge, hinge === 'left' ? styles.left : styles.right, { width: width * 0.04 }], stops: [shade(0.35, 0), shade(0.1, 0.5), shade(0, 1)] }), children] }));
76
- }
77
- /** The front cover: cloth with the title and an inset photo. */
78
- export function CoverView({ book, width, showImages = true }) {
79
- const color = COVER_THEMES[book.theme ?? 'classic'].ink;
80
- const serif = useSerif();
81
- const unit = width / 100;
82
- return (_jsx(Cloth, { book: book, width: width, hinge: "left", children: _jsxs(View, { style: [styles.panel, { margin: 6 * unit, paddingVertical: 8 * unit, paddingHorizontal: 6 * unit, borderColor: color }], children: [book.kicker ? (_jsx(Text, { style: [styles.kicker, { fontSize: 2.6 * unit, letterSpacing: 0.78 * unit, color }], children: book.kicker.toUpperCase() })) : null, _jsx(Text, { style: [
83
- serif.upright,
84
- styles.center,
85
- { marginTop: 5 * unit, fontSize: 9 * unit, lineHeight: 9.5 * unit, fontWeight: '500', color },
86
- ], children: book.title }), book.cover ? (_jsx(View, { style: [styles.coverPhoto, { marginTop: 7 * unit, borderWidth: 0.8 * unit, borderColor: color }], children: showImages && (_jsx(Image, { source: { uri: book.cover.src }, style: StyleSheet.absoluteFill, resizeMode: "cover", "aria-label": book.cover.alt ?? undefined })) })) : book.subtitle ? (_jsx(Text, { style: [serif.italic, styles.center, { marginTop: 6 * unit, fontSize: 4 * unit, color, opacity: 0.8 }], children: book.subtitle })) : null] }) }));
87
- }
88
- export function BackCoverView({ book, width }) {
89
- const color = COVER_THEMES[book.theme ?? 'classic'].ink;
90
- const unit = width / 100;
91
- return (_jsx(Cloth, { book: book, width: width, hinge: "right", children: _jsxs(View, { style: [styles.imprint, { bottom: 10 * unit }], children: [_jsx(Ornament, { size: 9 * unit, color: color }), book.imprint ? (_jsx(Text, { style: [styles.kicker, { marginTop: 2 * unit, fontSize: 2.4 * unit, letterSpacing: 0.72 * unit, color, opacity: 0.6 }], children: book.imprint.toUpperCase() })) : null] }) }));
92
- }
93
70
  /** Inside the front cover: a title page. */
94
71
  export function TitlePageView({ book, width, side, }) {
95
72
  const look = usePaper();
@@ -108,7 +85,7 @@ export function ColophonView({ book, width, side }) {
108
85
  export function BlankPageView({ width, side }) {
109
86
  return _jsx(Paper, { width: width, side: side });
110
87
  }
111
- function Ornament({ size, color }) {
88
+ export function Ornament({ size, color }) {
112
89
  return (_jsxs(Svg, { viewBox: "0 0 40 40", width: size, height: size, children: [_jsx(Circle, { cx: "20", cy: "20", r: "18.5", fill: "none", stroke: color, strokeOpacity: 0.6 }), _jsx(Path, { d: "M20 9 L 27 20 L 20 31 L 13 20 Z", fill: "none", stroke: color, strokeWidth: 1.4, strokeLinejoin: "round" }), _jsx(Circle, { cx: "20", cy: "20", r: "2.2", fill: color })] }));
113
90
  }
114
91
  const styles = StyleSheet.create({
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "@toneflix/flipbook-native",
3
- "version": "0.2.0",
4
- "description": "Bind photos into a page-turning album in React Native and Expo: fingertip page turns, prints set into paper, frames and layouts.",
3
+ "version": "0.2.2",
4
+ "description": "An expressive flipbook for creating personalised and interactive reading and photo viewing experiences, for React Native and Expo.",
5
5
  "license": "MIT",
6
+ "homepage": "https://toneflix.github.io/flipbook/frameworks/react-native",
7
+ "bugs": {
8
+ "url": "https://github.com/toneflix/flipbook/issues"
9
+ },
6
10
  "repository": {
7
11
  "type": "git",
8
12
  "url": "https://github.com/toneflix/flipbook",
@@ -25,7 +29,7 @@
25
29
  "assets"
26
30
  ],
27
31
  "dependencies": {
28
- "@toneflix/flipbook-core": "^0.2.0"
32
+ "@toneflix/flipbook-core": "^0.2.2"
29
33
  },
30
34
  "peerDependencies": {
31
35
  "expo-screen-orientation": "*",
package/src/Cover.tsx ADDED
@@ -0,0 +1,416 @@
1
+ import { useId, type ReactNode } from 'react'
2
+ import { Image, StyleSheet, Text, View, type TextStyle, type ViewStyle } from 'react-native'
3
+ import Svg, { ClipPath, Defs, Ellipse, Image as SvgImage, Path, RadialGradient, Rect, Stop } from 'react-native-svg'
4
+ import { COVER_THEMES, DECO, FLEURON, coverHasText, type CoverArt, type FlipbookBook, type FlipbookPhoto } from '@toneflix/flipbook-core'
5
+ import { Gradient } from './Gradient'
6
+ import { PAPERS, useSerif } from './look'
7
+ import { Ornament, PAGE_RATIO } from './pages'
8
+
9
+ type Side = 'left' | 'right'
10
+ type Corner = 'tl' | 'tr' | 'bl' | 'br'
11
+
12
+ const CORNERS: Corner[] = ['tl', 'tr', 'bl', 'br']
13
+ const MIRROR: Record<Corner, ViewStyle['transform']> = {
14
+ tl: undefined,
15
+ tr: [{ scaleX: -1 }],
16
+ bl: [{ scaleY: -1 }],
17
+ br: [{ scaleX: -1 }, { scaleY: -1 }],
18
+ }
19
+
20
+ function Art({ art, color, style }: { art: CoverArt; color: string; style: ViewStyle }) {
21
+ return (
22
+ <Svg viewBox={art.viewBox} preserveAspectRatio="none" style={style} pointerEvents="none">
23
+ {art.strokes.map(({ d, width, opacity = 1 }) => (
24
+ <Path key={d} d={d} fill="none" stroke={color} strokeWidth={width} strokeOpacity={opacity} />
25
+ ))}
26
+ {art.fills.map((d) => (
27
+ <Path key={d} d={d} fill={color} />
28
+ ))}
29
+ </Svg>
30
+ )
31
+ }
32
+
33
+ /** Gilt rules and a flourish in each corner. */
34
+ function GiltFrame({ unit, color }: { unit: number; color: string }) {
35
+ const size = 18 * unit
36
+ const at = 3.6 * unit
37
+ const place: Record<Corner, ViewStyle> = {
38
+ tl: { top: at, left: at },
39
+ tr: { top: at, right: at },
40
+ bl: { bottom: at, left: at },
41
+ br: { bottom: at, right: at },
42
+ }
43
+
44
+ return (
45
+ <>
46
+ <View pointerEvents="none" style={[styles.fill, { margin: 5 * unit, borderWidth: 0.35 * unit, borderColor: color, opacity: 0.85 }]} />
47
+ <View
48
+ pointerEvents="none"
49
+ style={[styles.fill, { margin: 6.6 * unit, borderWidth: 0.15 * unit, borderColor: color, opacity: 0.85 }]}
50
+ />
51
+ {CORNERS.map((corner) => (
52
+ <Art
53
+ key={corner}
54
+ art={FLEURON}
55
+ color={color}
56
+ style={{ position: 'absolute', width: size, height: size, ...place[corner], transform: MIRROR[corner] }}
57
+ />
58
+ ))}
59
+ </>
60
+ )
61
+ }
62
+
63
+ /** A photo clipped to an oval or an arch, ruled in the ink. */
64
+ function ShapedPhoto({
65
+ photo,
66
+ shape,
67
+ width,
68
+ height,
69
+ unit,
70
+ color,
71
+ show,
72
+ }: {
73
+ photo: FlipbookPhoto
74
+ shape: 'oval' | 'arch'
75
+ width: number
76
+ height: number
77
+ unit: number
78
+ color: string
79
+ show: boolean
80
+ }) {
81
+ const id = `c${useId().replace(/[^a-zA-Z0-9]/g, '')}`
82
+ const r = width / 2
83
+ const arch = `M0 ${height}V${r}A${r} ${r} 0 0 1 ${width} ${r}V${height}Z`
84
+ const outline = shape === 'oval' ? <Ellipse cx={r} cy={height / 2} rx={r - 0.3 * unit} ry={height / 2 - 0.3 * unit} /> : <Path d={arch} />
85
+
86
+ return (
87
+ <Svg width={width} height={height}>
88
+ <Defs>
89
+ <ClipPath id={id}>{outline}</ClipPath>
90
+ </Defs>
91
+ {show ? (
92
+ <SvgImage href={{ uri: photo.src }} width={width} height={height} preserveAspectRatio="xMidYMid slice" clipPath={`url(#${id})`} />
93
+ ) : (
94
+ <Rect width={width} height={height} fill={color} fillOpacity={0.15} clipPath={`url(#${id})`} />
95
+ )}
96
+ {shape === 'oval' ? (
97
+ <Ellipse
98
+ cx={r}
99
+ cy={height / 2}
100
+ rx={r - 0.3 * unit}
101
+ ry={height / 2 - 0.3 * unit}
102
+ fill="none"
103
+ stroke={color}
104
+ strokeWidth={0.6 * unit}
105
+ />
106
+ ) : (
107
+ <Path d={arch} fill="none" stroke={color} strokeWidth={1 * unit} />
108
+ )}
109
+ </Svg>
110
+ )
111
+ }
112
+
113
+ const Photo = ({ photo, show }: { photo: FlipbookPhoto; show: boolean }) =>
114
+ show ? <Image source={{ uri: photo.src }} style={StyleSheet.absoluteFill} resizeMode="cover" aria-label={photo.alt ?? undefined} /> : null
115
+
116
+ /** Cover board: cloth, light, the hinge shadow along the spine and a grain finish. */
117
+ function Board({ book, width, hinge, children }: { book: Pick<FlipbookBook, 'theme'>; width: number; hinge: Side; children?: ReactNode }) {
118
+ const theme = COVER_THEMES[book.theme ?? 'classic']
119
+ const id = useId().replace(/[^a-zA-Z0-9]/g, '')
120
+ const shade = (a: number, offset: number) => ({ offset, color: '#000', opacity: a })
121
+
122
+ return (
123
+ <View style={[styles.board, { width, height: width / PAGE_RATIO, backgroundColor: theme.cloth }]}>
124
+ {children}
125
+ <Svg style={StyleSheet.absoluteFill} width="100%" height="100%" pointerEvents="none">
126
+ <Defs>
127
+ <RadialGradient id={`sheen${id}`} cx="30%" cy="20%" rx="45%" ry="45%">
128
+ <Stop offset="0" stopColor="#fff" stopOpacity={0.1} />
129
+ <Stop offset="1" stopColor="#fff" stopOpacity={0} />
130
+ </RadialGradient>
131
+ <RadialGradient id={`edge${id}`} cx="50%" cy="50%" rx="70%" ry="70%">
132
+ <Stop offset="0.6" stopColor="#000" stopOpacity={0} />
133
+ <Stop offset="1" stopColor="#000" stopOpacity={0.25} />
134
+ </RadialGradient>
135
+ </Defs>
136
+ <Rect width="100%" height="100%" fill={`url(#sheen${id})`} />
137
+ <Rect width="100%" height="100%" fill={`url(#edge${id})`} />
138
+ </Svg>
139
+ <Image
140
+ source={PAPERS.ivory.grain}
141
+ resizeMode="repeat"
142
+ pointerEvents="none"
143
+ style={[StyleSheet.absoluteFill, { opacity: 0.25, mixBlendMode: 'soft-light' }]}
144
+ />
145
+ <Gradient
146
+ to={hinge === 'left' ? 'right' : 'left'}
147
+ style={[styles.hinge, hinge === 'left' ? styles.left : styles.right, { width: width * 0.04 }]}
148
+ stops={[shade(0.35, 0), shade(0.1, 0.5), shade(0, 1)]}
149
+ />
150
+ </View>
151
+ )
152
+ }
153
+
154
+ export interface CoverViewProps {
155
+ book: Pick<FlipbookBook, 'title' | 'subtitle' | 'theme' | 'cover' | 'kicker' | 'coverDesign' | 'coverText'>
156
+ width: number
157
+ showImages?: boolean
158
+ }
159
+
160
+ /** The front cover, laid out by the book's `coverDesign`. */
161
+ export function CoverView({ book, width, showImages = true }: CoverViewProps) {
162
+ const color = COVER_THEMES[book.theme ?? 'classic'].ink
163
+ const serif = useSerif()
164
+ const unit = width / 100
165
+ const text = coverHasText(book)
166
+ const stamped: TextStyle = {
167
+ textShadowColor: 'rgba(0,0,0,0.35)',
168
+ textShadowOffset: { width: 0, height: 0.15 * unit },
169
+ textShadowRadius: 0,
170
+ }
171
+
172
+ const kicker = (ink = color) =>
173
+ book.kicker ? (
174
+ <Text style={[styles.kicker, { fontSize: 2.6 * unit, letterSpacing: 0.78 * unit, color: ink }]}>{book.kicker.toUpperCase()}</Text>
175
+ ) : null
176
+ const title = (size: number, ink = color, extra?: TextStyle) => (
177
+ <Text
178
+ style={[
179
+ serif.upright,
180
+ styles.center,
181
+ stamped,
182
+ { marginTop: 4 * unit, fontSize: size * unit, lineHeight: size * 1.08 * unit, fontWeight: '500', color: ink },
183
+ extra,
184
+ ]}
185
+ >
186
+ {book.title}
187
+ </Text>
188
+ )
189
+ const subtitle = (size = 4, ink = color) =>
190
+ book.subtitle ? (
191
+ <Text
192
+ style={[
193
+ serif.italic,
194
+ styles.center,
195
+ { marginTop: 3 * unit, fontSize: size * unit, lineHeight: size * 1.35 * unit, color: ink, opacity: 0.85 },
196
+ ]}
197
+ >
198
+ {book.subtitle}
199
+ </Text>
200
+ ) : null
201
+
202
+ const designs = {
203
+ classic: () => (
204
+ <View style={[styles.panel, { margin: 6 * unit, paddingVertical: 8 * unit, paddingHorizontal: 6 * unit, borderColor: color }]}>
205
+ {text && kicker()}
206
+ {text && title(9)}
207
+ {book.cover ? (
208
+ <View style={[styles.coverPhoto, { marginTop: 7 * unit, borderWidth: 0.8 * unit, borderColor: color }]}>
209
+ <Photo photo={book.cover} show={showImages} />
210
+ </View>
211
+ ) : (
212
+ text && subtitle()
213
+ )}
214
+ </View>
215
+ ),
216
+ gilt: () => (
217
+ <>
218
+ <GiltFrame unit={unit} color={color} />
219
+ <View style={[styles.stack, { top: 14 * unit, bottom: 14 * unit, left: 12 * unit, right: 12 * unit }]}>
220
+ {text && kicker()}
221
+ {text && title(8)}
222
+ {book.cover && (
223
+ <View style={{ marginTop: 6 * unit }}>
224
+ <ShapedPhoto
225
+ photo={book.cover}
226
+ shape="oval"
227
+ width={52 * unit}
228
+ height={64 * unit}
229
+ unit={unit}
230
+ color={color}
231
+ show={showImages}
232
+ />
233
+ </View>
234
+ )}
235
+ {text && subtitle()}
236
+ </View>
237
+ </>
238
+ ),
239
+ window: () => (
240
+ <>
241
+ <View style={[styles.window, { top: 10 * unit, left: 12 * unit, right: 12 * unit, height: 76 * unit, borderRadius: 0.6 * unit }]}>
242
+ {book.cover ? (
243
+ <Photo photo={book.cover} show={showImages} />
244
+ ) : (
245
+ <View style={[styles.fill, styles.centered]}>
246
+ <Ornament size={16 * unit} color={color} />
247
+ </View>
248
+ )}
249
+ <Gradient
250
+ to="bottom"
251
+ style={[styles.edge, { top: 0, height: 4 * unit }]}
252
+ stops={[
253
+ { offset: 0, color: '#000', opacity: 0.55 },
254
+ { offset: 1, color: '#000', opacity: 0 },
255
+ ]}
256
+ />
257
+ <Gradient
258
+ to="right"
259
+ style={[styles.edgeV, { left: 0, width: 2 * unit }]}
260
+ stops={[
261
+ { offset: 0, color: '#000', opacity: 0.3 },
262
+ { offset: 1, color: '#000', opacity: 0 },
263
+ ]}
264
+ />
265
+ <Gradient
266
+ to="left"
267
+ style={[styles.edgeV, { right: 0, width: 2 * unit }]}
268
+ stops={[
269
+ { offset: 0, color: '#000', opacity: 0.25 },
270
+ { offset: 1, color: '#000', opacity: 0 },
271
+ ]}
272
+ />
273
+ </View>
274
+ {text && (
275
+ <View style={[styles.stack, { top: 90 * unit, bottom: 8 * unit, left: 10 * unit, right: 10 * unit }]}>
276
+ {kicker()}
277
+ {title(7.5, color, { textShadowColor: 'rgba(0,0,0,0.45)', textShadowOffset: { width: 0, height: -0.15 * unit } })}
278
+ {subtitle(3.4)}
279
+ </View>
280
+ )}
281
+ </>
282
+ ),
283
+ label: () => (
284
+ <>
285
+ {book.cover && (
286
+ <View style={[styles.print, { top: 11 * unit, left: 16 * unit, right: 16 * unit, height: 62 * unit, padding: 2.4 * unit }]}>
287
+ <View style={[styles.flex, styles.clip]}>
288
+ <Photo photo={book.cover} show={showImages} />
289
+ </View>
290
+ <Svg style={StyleSheet.absoluteFill} viewBox="0 0 100 100" preserveAspectRatio="none" pointerEvents="none">
291
+ <Path d="M0 0H9L0 9Z M100 0V9L91 0Z M0 100V91L9 100Z M100 100H91L100 91Z" fill="#1f1b18" />
292
+ </Svg>
293
+ </View>
294
+ )}
295
+ {text && (
296
+ <View style={[styles.label, { top: (book.cover ? 84 : 44) * unit, left: 18 * unit, right: 18 * unit, padding: 4 * unit }]}>
297
+ <View
298
+ pointerEvents="none"
299
+ style={[styles.fill, { margin: 1.2 * unit, borderWidth: 0.2 * unit, borderColor: 'rgba(60,45,30,0.5)' }]}
300
+ />
301
+ {kicker('#3a2f25')}
302
+ {title(6.4, '#3a2f25', { marginTop: 1 * unit, textShadowColor: 'transparent' })}
303
+ {subtitle(3, '#3a2f25')}
304
+ </View>
305
+ )}
306
+ </>
307
+ ),
308
+ deco: () => (
309
+ <>
310
+ <Art art={DECO} color={color} style={StyleSheet.absoluteFill as ViewStyle} />
311
+ <View
312
+ style={[styles.stack, { top: 17 * unit, bottom: 10 * unit, left: 14 * unit, right: 14 * unit, justifyContent: 'flex-start' }]}
313
+ >
314
+ {text && kicker()}
315
+ {book.cover && (
316
+ <View style={{ marginTop: 4 * unit }}>
317
+ <ShapedPhoto
318
+ photo={book.cover}
319
+ shape="arch"
320
+ width={56 * unit}
321
+ height={62 * unit}
322
+ unit={unit}
323
+ color={color}
324
+ show={showImages}
325
+ />
326
+ </View>
327
+ )}
328
+ {text && title(6.6, color, { marginTop: 7 * unit, fontWeight: '400', letterSpacing: 0.8 * unit, textTransform: 'uppercase' })}
329
+ {text && subtitle(3.4)}
330
+ </View>
331
+ </>
332
+ ),
333
+ image: () => (
334
+ <>
335
+ {book.cover && <Photo photo={book.cover} show={showImages} />}
336
+ {book.cover && (
337
+ <View
338
+ pointerEvents="none"
339
+ style={[StyleSheet.absoluteFill, { backgroundColor: color, opacity: 0.18, mixBlendMode: 'soft-light' }]}
340
+ />
341
+ )}
342
+ {text &&
343
+ (book.cover ? (
344
+ <View style={[styles.caption, { paddingTop: 26 * unit, paddingHorizontal: 10 * unit, paddingBottom: 10 * unit }]}>
345
+ <Gradient
346
+ to="top"
347
+ stops={[
348
+ { offset: 0, color: '#000', opacity: 0.65 },
349
+ { offset: 0.55, color: '#000', opacity: 0.25 },
350
+ { offset: 1, color: '#000', opacity: 0 },
351
+ ]}
352
+ />
353
+ {kicker('#fff')}
354
+ {title(9, '#fff', { textShadowColor: 'rgba(0,0,0,0.5)', textShadowRadius: 1.2 * unit })}
355
+ {subtitle(4, '#fff')}
356
+ </View>
357
+ ) : (
358
+ <View style={[styles.fill, styles.centered, { padding: 10 * unit }]}>
359
+ {kicker()}
360
+ {title(9)}
361
+ {subtitle()}
362
+ </View>
363
+ ))}
364
+ </>
365
+ ),
366
+ }
367
+
368
+ return (
369
+ <Board book={book} width={width} hinge="left">
370
+ {designs[book.coverDesign ?? 'classic']()}
371
+ </Board>
372
+ )
373
+ }
374
+
375
+ /** The back cover: the same board, with the imprint. */
376
+ export function BackCoverView({ book, width }: { book: Pick<FlipbookBook, 'theme' | 'imprint' | 'coverDesign'>; width: number }) {
377
+ const color = COVER_THEMES[book.theme ?? 'classic'].ink
378
+ const unit = width / 100
379
+
380
+ return (
381
+ <Board book={book} width={width} hinge="right">
382
+ {book.coverDesign === 'gilt' && <GiltFrame unit={unit} color={color} />}
383
+ <View style={[styles.imprint, { bottom: 10 * unit }]}>
384
+ <Ornament size={9 * unit} color={color} />
385
+ {book.imprint ? (
386
+ <Text style={[styles.kicker, { marginTop: 2 * unit, fontSize: 2.4 * unit, letterSpacing: 0.72 * unit, color, opacity: 0.6 }]}>
387
+ {book.imprint.toUpperCase()}
388
+ </Text>
389
+ ) : null}
390
+ </View>
391
+ </Board>
392
+ )
393
+ }
394
+
395
+ const styles = StyleSheet.create({
396
+ board: { overflow: 'hidden' },
397
+ fill: { position: 'absolute', top: 0, right: 0, bottom: 0, left: 0 },
398
+ flex: { flex: 1 },
399
+ clip: { overflow: 'hidden' },
400
+ centered: { alignItems: 'center', justifyContent: 'center' },
401
+ center: { textAlign: 'center' },
402
+ hinge: { position: 'absolute', top: 0, bottom: 0 },
403
+ left: { left: 0 },
404
+ right: { right: 0 },
405
+ panel: { flex: 1, alignItems: 'center', borderWidth: StyleSheet.hairlineWidth },
406
+ kicker: { textAlign: 'center', opacity: 0.7 },
407
+ coverPhoto: { width: '100%', flex: 1, maxHeight: '62%', overflow: 'hidden' },
408
+ stack: { position: 'absolute', alignItems: 'center', justifyContent: 'center' },
409
+ window: { position: 'absolute', overflow: 'hidden', backgroundColor: 'rgba(0,0,0,0.12)' },
410
+ edge: { position: 'absolute', left: 0, right: 0 },
411
+ edgeV: { position: 'absolute', top: 0, bottom: 0 },
412
+ print: { position: 'absolute', backgroundColor: '#f7f2e7', transform: [{ rotate: '-2.5deg' }], boxShadow: '0 3px 8px rgba(0,0,0,0.4)' },
413
+ label: { position: 'absolute', alignItems: 'center', backgroundColor: '#f4eddc', boxShadow: '0 1px 4px rgba(0,0,0,0.3)' },
414
+ caption: { position: 'absolute', left: 0, right: 0, bottom: 0, alignItems: 'center' },
415
+ imprint: { position: 'absolute', left: 0, right: 0, alignItems: 'center' },
416
+ })
package/src/Flipbook.tsx CHANGED
@@ -41,7 +41,8 @@ import {
41
41
  import { FoldLayout, type Band, type Role } from './geometry'
42
42
  import { Gradient } from './Gradient'
43
43
  import { BookLookProvider, type BookFonts } from './look'
44
- import { BackCoverView, BlankPageView, ColophonView, CoverView, PAGE_RATIO, PageView, TitlePageView } from './pages'
44
+ import { BackCoverView, CoverView } from './Cover'
45
+ import { BlankPageView, ColophonView, PAGE_RATIO, PageView, TitlePageView } from './pages'
45
46
  import { lockLandscape } from './orientation'
46
47
 
47
48
  export interface FlipbookPlace {
package/src/index.ts CHANGED
@@ -1,17 +1,7 @@
1
1
  export * from '@toneflix/flipbook-core'
2
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'
3
+ export { PageView, Paper, TitlePageView, ColophonView, BlankPageView, PAGE_RATIO, type PageViewProps } from './pages'
4
+ export { CoverView, BackCoverView, type CoverViewProps } from './Cover'
15
5
  export { PrintedPhoto, type PrintedPhotoProps } from './PrintedPhoto'
16
6
  export { BookLookProvider, useBookLook, usePaper, useSerif, ink, PAPERS, type BookLook, type BookFonts, type PaperLook } from './look'
17
7
  export { Gradient, type GradientStop } from './Gradient'
package/src/look.tsx CHANGED
@@ -20,7 +20,7 @@ export interface PaperLook {
20
20
  vignette: string
21
21
  }
22
22
 
23
- const fine = { texture: require('../assets/textures/paper-fine@2x.webp'), grain: require('../assets/textures/grain-fine@2x.webp') }
23
+ const fine = { texture: require('../assets/textures/paper-fine.webp'), grain: require('../assets/textures/grain-fine.webp') }
24
24
 
25
25
  const ivory: PaperLook = {
26
26
  background: '#fdf9f0',
@@ -46,9 +46,9 @@ export const PAPERS: Record<PaperColor, PaperLook> = {
46
46
  plateTint: 'rgba(90,70,40,0.03)',
47
47
  plateLow: 'rgba(80,50,20,0.16)',
48
48
  plateHigh: 'rgba(255,245,225,0.35)',
49
- texture: require('../assets/textures/paper-kraft@2x.webp'),
49
+ texture: require('../assets/textures/paper-kraft.webp'),
50
50
  textureOpacity: 0.45,
51
- grain: require('../assets/textures/grain-kraft@2x.webp'),
51
+ grain: require('../assets/textures/grain-kraft.webp'),
52
52
  grainOpacity: 0.35,
53
53
  light: 'rgba(255,255,255,0.18)',
54
54
  vignette: 'rgba(80,50,20,0.12)',
@@ -60,9 +60,9 @@ export const PAPERS: Record<PaperColor, PaperLook> = {
60
60
  plateTint: 'rgba(0,0,0,0.08)',
61
61
  plateLow: 'rgba(0,0,0,0.35)',
62
62
  plateHigh: 'rgba(255,255,255,0.07)',
63
- texture: require('../assets/textures/paper-charcoal@2x.webp'),
63
+ texture: require('../assets/textures/paper-charcoal.webp'),
64
64
  textureOpacity: 0.25,
65
- grain: require('../assets/textures/grain-charcoal@2x.webp'),
65
+ grain: require('../assets/textures/grain-charcoal.webp'),
66
66
  grainOpacity: 0.22,
67
67
  light: 'rgba(255,255,255,0.05)',
68
68
  vignette: 'rgba(0,0,0,0.25)',
package/src/pages.tsx CHANGED
@@ -1,7 +1,7 @@
1
1
  import { useId, type ReactNode } from 'react'
2
2
  import { Image, Pressable, StyleSheet, Text, View, type ViewStyle } from 'react-native'
3
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'
4
+ import { PLACED_LAYOUTS, photoFrame, type FlipbookBook, type FlipbookPage, type PageBox } from '@toneflix/flipbook-core'
5
5
  import { Gradient } from './Gradient'
6
6
  import { ink, useBookLook, usePaper, useSerif, type PaperLook } from './look'
7
7
  import { PrintedPhoto } from './PrintedPhoto'
@@ -250,99 +250,6 @@ function Gutter({ side }: { side: Side }) {
250
250
  )
251
251
  }
252
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
253
  /** Inside the front cover: a title page. */
347
254
  export function TitlePageView({
348
255
  book,
@@ -406,7 +313,7 @@ export function BlankPageView({ width, side }: { width: number; side?: Side }) {
406
313
  return <Paper width={width} side={side} />
407
314
  }
408
315
 
409
- function Ornament({ size, color }: { size: number; color: string }) {
316
+ export function Ornament({ size, color }: { size: number; color: string }) {
410
317
  return (
411
318
  <Svg viewBox="0 0 40 40" width={size} height={size}>
412
319
  <Circle cx="20" cy="20" r="18.5" fill="none" stroke={color} strokeOpacity={0.6} />