@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Toneflix
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,24 @@
1
- # Temporary Holding Version
1
+ # @toneflix/flipbook-native
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.
4
+
5
+ ```bash
6
+ npx expo install @toneflix/flipbook-native react-native-svg expo-screen-orientation
7
+ ```
8
+
9
+ `expo-screen-orientation` is optional: with it, the screen locks to landscape while the book is open; without it, phones held upright are asked to turn sideways.
10
+
11
+ ```tsx
12
+ import { View } from 'react-native'
13
+ import { Flipbook } from '@toneflix/flipbook-native'
14
+
15
+ export function Book({ book }) {
16
+ return (
17
+ <View style={{ flex: 1 }}>
18
+ <Flipbook book={book} fonts={{ serif: 'Fraunces_400Regular', serifItalic: 'Fraunces_400Regular_Italic' }} />
19
+ </View>
20
+ )
21
+ }
22
+ ```
23
+
24
+ See the [documentation](https://toneflix.github.io/flipbook/guide/react-native).
@@ -0,0 +1,53 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type StyleProp, type ViewStyle } from 'react-native';
3
+ import { FlipbookReader, type Face, type FlipbookBook, type FlipbookLabels, type GripOptions, type TurnPhysics } from '@toneflix/flipbook-core';
4
+ import { type BookFonts } from './look';
5
+ export interface FlipbookPlace {
6
+ /** How many leaves lie on the left: 0 is the closed front cover. */
7
+ flipped: number;
8
+ label: string;
9
+ faces: Face[];
10
+ }
11
+ export interface FlipbookProps {
12
+ book: FlipbookBook;
13
+ /** Open at this page (1-based) instead of the cover. */
14
+ startPage?: number;
15
+ /** Two pages side by side, one at a time, or whichever fits the space (default). */
16
+ layout?: 'auto' | 'spread' | 'single';
17
+ /** Show the top and bottom bars. */
18
+ controls?: boolean;
19
+ /** Hide the bars after this many ms without a touch, or never with false. */
20
+ autoHide?: number | false;
21
+ thumbnails?: boolean;
22
+ /**
23
+ * Read sideways like a real book: locks the screen to landscape while the book is open when
24
+ * `expo-screen-orientation` is installed, and otherwise asks the reader to turn the phone.
25
+ */
26
+ landscape?: boolean;
27
+ /** Covers swing on the spine like board instead of curling like paper. */
28
+ stiffCovers?: boolean;
29
+ physics?: Partial<TurnPhysics>;
30
+ grip?: Partial<GripOptions>;
31
+ /** Strength of the light and shadow around a turning page, from 0 to 1 or more. */
32
+ shading?: number;
33
+ /** Backdrop colour behind the book. Defaults to a glow tinted by the cover. */
34
+ background?: string;
35
+ fonts?: BookFonts;
36
+ labels?: Partial<FlipbookLabels>;
37
+ /** Extra controls at the start and end of the top bar, e.g. a close button. */
38
+ topLeft?: ReactNode;
39
+ topRight?: ReactNode;
40
+ style?: StyleProp<ViewStyle>;
41
+ onPageChange?: (place: FlipbookPlace) => void;
42
+ /** A page started to turn over, e.g. to play a sound. */
43
+ onFlip?: () => void;
44
+ }
45
+ export interface FlipbookHandle {
46
+ next(): void;
47
+ prev(): void;
48
+ go(flipped: number): void;
49
+ goToPage(page: number): void;
50
+ readonly reader: FlipbookReader;
51
+ }
52
+ /** A photo book that opens across two pages, turned by dragging, flicking, tapping or the buttons. */
53
+ export declare const Flipbook: import("react").ForwardRefExoticComponent<FlipbookProps & import("react").RefAttributes<FlipbookHandle>>;
@@ -0,0 +1,280 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useReducer, useRef, useState, } from 'react';
3
+ import { AccessibilityInfo, Animated, PanResponder, Pressable, ScrollView, StyleSheet, Text, View, useWindowDimensions, } from 'react-native';
4
+ import Svg, { Defs, Path, RadialGradient, Rect, Stop } from 'react-native-svg';
5
+ import { BookLayout, COVER_THEMES, DEFAULT_LABELS, FlipbookReader, } from '@toneflix/flipbook-core';
6
+ import { FoldLayout } from './geometry';
7
+ import { Gradient } from './Gradient';
8
+ import { BookLookProvider } from './look';
9
+ import { BackCoverView, BlankPageView, ColophonView, CoverView, PAGE_RATIO, PageView, TitlePageView } from './pages';
10
+ import { lockLandscape } from './orientation';
11
+ /** One face's content. Memoised so turning a page only moves its wrappers. */
12
+ const FaceContent = memo(function FaceContent({ face, book, width, side, }) {
13
+ switch (face.kind) {
14
+ case 'cover':
15
+ return _jsx(CoverView, { book: book, width: width });
16
+ case 'back':
17
+ return _jsx(BackCoverView, { book: book, width: width });
18
+ case 'title':
19
+ return _jsx(TitlePageView, { book: book, width: width, side: side });
20
+ case 'colophon':
21
+ return _jsx(ColophonView, { book: book, width: width, side: side });
22
+ case 'blank':
23
+ return _jsx(BlankPageView, { width: width, side: side });
24
+ case 'page':
25
+ return _jsx(PageView, { page: book.pages[face.index], width: width, number: face.index + 1, side: side });
26
+ }
27
+ });
28
+ const BAND_STOPS = {
29
+ under: [
30
+ { offset: 0, color: '#000', opacity: 0.42 },
31
+ { offset: 0.17, color: '#000', opacity: 0.2 },
32
+ { offset: 0.53, color: '#000', opacity: 0.06 },
33
+ { offset: 1, color: '#000', opacity: 0 },
34
+ ],
35
+ bend: [
36
+ { offset: 0, color: '#000', opacity: 0.16 },
37
+ { offset: 0.5, color: '#000', opacity: 0.04 },
38
+ { offset: 1, color: '#000', opacity: 0 },
39
+ ],
40
+ curl: [
41
+ { offset: 0, color: '#000', opacity: 0.3 },
42
+ { offset: 0.035, color: '#000', opacity: 0.1 },
43
+ { offset: 0.11, color: '#fff', opacity: 0.2 },
44
+ { offset: 0.25, color: '#fff', opacity: 0.06 },
45
+ { offset: 0.6, color: '#000', opacity: 0.05 },
46
+ { offset: 1, color: '#000', opacity: 0.12 },
47
+ ],
48
+ };
49
+ /** A photo book that opens across two pages, turned by dragging, flicking, tapping or the buttons. */
50
+ export const Flipbook = forwardRef(function Flipbook(props, ref) {
51
+ const { book, startPage, layout: layoutMode = 'auto', controls = true, autoHide = 3200, thumbnails = true, landscape = true, stiffCovers = true, physics, grip, shading = 1, background, fonts, topLeft, topRight, style, onPageChange, onFlip, } = props;
52
+ const labels = { ...DEFAULT_LABELS, ...props.labels };
53
+ const screen = useWindowDimensions();
54
+ const [stage, setStage] = useState({ width: 0, height: 0 });
55
+ const [portraitChoice, setPortraitChoice] = useState('ask');
56
+ const [locked, setLocked] = useState(false);
57
+ const [showPages, setShowPages] = useState(false);
58
+ const [, rerender] = useReducer((n) => n + 1, 0);
59
+ const reducedMotion = useRef(false);
60
+ useEffect(() => {
61
+ AccessibilityInfo.isReduceMotionEnabled?.().then((on) => (reducedMotion.current = on));
62
+ const subscription = AccessibilityInfo.addEventListener?.('reduceMotionChanged', (on) => (reducedMotion.current = on));
63
+ return () => subscription?.remove();
64
+ }, []);
65
+ useEffect(() => {
66
+ if (!landscape)
67
+ return;
68
+ let release;
69
+ let cancelled = false;
70
+ lockLandscape().then((unlock) => {
71
+ if (cancelled)
72
+ return unlock?.();
73
+ release = unlock ?? undefined;
74
+ setLocked(!!unlock);
75
+ });
76
+ return () => {
77
+ cancelled = true;
78
+ release?.();
79
+ };
80
+ }, [landscape]);
81
+ // Phones held upright are asked to turn sideways when the screen can't be locked; single pages are the fallback.
82
+ const portrait = screen.height > screen.width;
83
+ const showRotatePrompt = landscape && !locked && portrait && portraitChoice === 'ask' && layoutMode !== 'single';
84
+ const narrow = stage.width > 0 && stage.width / stage.height < 0.95;
85
+ const spread = layoutMode === 'auto' ? !(portrait && portraitChoice === 'single') && !narrow : layoutMode === 'spread';
86
+ const pageCount = book.pages.length;
87
+ const layout = useMemo(() => new BookLayout(pageCount, { spread, stiffCovers }), [pageCount, spread, stiffCovers]);
88
+ // Page size: as large as fits, keeping the page shape, with room for the controls.
89
+ const across = spread ? 2 : 1;
90
+ const marginY = !controls || stage.height < 500 ? 16 : Math.min(150, stage.height * 0.16);
91
+ const pageW = Math.floor(Math.max(0, Math.min((stage.height - marginY) * PAGE_RATIO, (stage.width - (spread ? 48 : 16)) / across)));
92
+ const pageH = Math.floor(pageW / PAGE_RATIO);
93
+ const handlers = useRef({ onFlip });
94
+ handlers.current = { onFlip };
95
+ const [reader] = useState(() => new FlipbookReader(layout, { frame: rerender, change: rerender, flip: () => handlers.current.onFlip?.() }, { flipped: layout.flippedForPage(startPage), physics, grip, reducedMotion: () => reducedMotion.current }));
96
+ useLayoutEffect(() => reader.configure({ layout, width: pageW, height: pageH }), [reader, layout, pageW, pageH]);
97
+ useEffect(() => {
98
+ reader.engine.physics = { ...reader.engine.physics, ...physics };
99
+ reader.grip = { ...reader.grip, ...grip };
100
+ }, [reader, physics, grip]);
101
+ useEffect(() => () => reader.destroy(), [reader]);
102
+ const { flipped, active } = reader;
103
+ const place = `${layout.spread}:${flipped}`;
104
+ const onPageChangeRef = useRef(onPageChange);
105
+ onPageChangeRef.current = onPageChange;
106
+ useEffect(() => {
107
+ onPageChangeRef.current?.({ flipped: reader.flipped, label: reader.label, faces: reader.layout.visible(reader.flipped) });
108
+ }, [place, reader]);
109
+ useImperativeHandle(ref, () => ({
110
+ next: () => reader.next(),
111
+ prev: () => reader.prev(),
112
+ go: (target) => reader.go(target),
113
+ goToPage: (page) => reader.go(reader.layout.flippedForPage(page)),
114
+ reader,
115
+ }), [reader]);
116
+ // ------------------------------------------------- Controls that hide while reading
117
+ const chrome = useRef(new Animated.Value(1)).current;
118
+ const [chromeShown, setChromeShown] = useState(true);
119
+ const hideTimer = useRef(undefined);
120
+ const poke = useCallback(() => {
121
+ clearTimeout(hideTimer.current);
122
+ setChromeShown(true);
123
+ Animated.timing(chrome, { toValue: 1, duration: 200, useNativeDriver: true }).start();
124
+ if (autoHide === false)
125
+ return;
126
+ hideTimer.current = setTimeout(() => {
127
+ setChromeShown(false);
128
+ Animated.timing(chrome, { toValue: 0, duration: 300, useNativeDriver: true }).start();
129
+ }, autoHide);
130
+ }, [autoHide, chrome]);
131
+ useEffect(() => {
132
+ poke();
133
+ return () => clearTimeout(hideTimer.current);
134
+ }, [poke]);
135
+ // ------------------------------------------------- A closed book sits centred
136
+ const settled = reader.settled;
137
+ const shift = layout.spread && settled ? (reader.atStart ? -pageW / 2 : reader.atEnd ? pageW / 2 : 0) : 0;
138
+ const shiftValue = useRef(new Animated.Value(shift)).current;
139
+ useEffect(() => {
140
+ Animated.timing(shiftValue, { toValue: shift, duration: 600, useNativeDriver: true }).start();
141
+ }, [shift, shiftValue]);
142
+ // ------------------------------------------------- Touch: grab, drag, flick, tap
143
+ const geometry = useRef({ stage, pageW, pageH, across, spread: layout.spread, shift });
144
+ geometry.current = { stage, pageW, pageH, across, spread: layout.spread, shift };
145
+ const grant = useRef({ x: 0, y: 0 });
146
+ /** A point on the stage measured from the top of the spine. */
147
+ const toBook = (p) => {
148
+ const g = geometry.current;
149
+ const left = (g.stage.width - g.pageW * g.across) / 2 + g.shift;
150
+ const top = (g.stage.height - g.pageH) / 2;
151
+ return { x: p.x - left - (g.spread ? g.pageW : 0), y: p.y - top };
152
+ };
153
+ const responder = useMemo(() => PanResponder.create({
154
+ onStartShouldSetPanResponder: () => true,
155
+ onMoveShouldSetPanResponder: () => true,
156
+ onPanResponderTerminationRequest: () => false,
157
+ onPanResponderGrant: (e) => {
158
+ grant.current = { x: e.nativeEvent.locationX, y: e.nativeEvent.locationY };
159
+ poke();
160
+ reader.down(toBook(grant.current), 1);
161
+ },
162
+ onPanResponderMove: (_, g) => {
163
+ reader.move(toBook({ x: grant.current.x + g.dx, y: grant.current.y + g.dy }), 1);
164
+ },
165
+ onPanResponderRelease: (_, g) => {
166
+ const at = { x: grant.current.x + g.dx, y: grant.current.y + g.dy };
167
+ if (reader.up(toBook(at), 1) !== 'tap')
168
+ return;
169
+ // A tap on the outer thirds turns the page.
170
+ const width = geometry.current.stage.width;
171
+ if (at.x > (width * 2) / 3)
172
+ reader.next();
173
+ else if (at.x < width / 3)
174
+ reader.prev();
175
+ },
176
+ onPanResponderTerminate: () => reader.cancel(),
177
+ }), [reader, poke]);
178
+ // ------------------------------------------------- Faces around the opening
179
+ const fold = new FoldLayout(pageW, pageH, active, active ? reader.engine.frame : null, shading);
180
+ const leaves = layout.leaves;
181
+ const turnLeaf = active?.leaf ?? null;
182
+ const turnDir = active?.dir ?? 1;
183
+ const rightLeaf = turnLeaf !== null ? turnLeaf + 1 : flipped;
184
+ const leftLeaf = turnLeaf !== null ? turnLeaf - 1 : flipped - 1;
185
+ const faces = [];
186
+ for (let k = Math.max(0, flipped - 2); k <= Math.min(leaves.length - 1, flipped + 1) && pageW > 0; k++) {
187
+ for (const side of ['front', 'back']) {
188
+ const facesUp = (side === 'front') === (turnDir === 1);
189
+ const role = k === turnLeaf
190
+ ? facesUp
191
+ ? 'turning'
192
+ : 'flap'
193
+ : side === 'front'
194
+ ? k === rightLeaf
195
+ ? 'right'
196
+ : 'spare'
197
+ : k === leftLeaf && layout.spread
198
+ ? 'left'
199
+ : 'spare';
200
+ if (role === 'spare')
201
+ continue;
202
+ const onLeft = role === 'left' || (role === 'turning' && turnDir === -1 && !active?.stiff);
203
+ const placement = fold.place(role, side, onLeft);
204
+ const z = { right: 1, left: 1, turning: 3, flap: 6 }[role];
205
+ faces.push(_jsx(View, { style: [placement.clip, { zIndex: z }], pointerEvents: "none", children: _jsx(View, { style: placement.frame, children: _jsxs(View, { style: placement.face, children: [_jsx(FaceContent, { face: side === 'front' ? leaves[k].front : leaves[k].back, book: book, width: pageW, side: side === 'front' ? 'right' : 'left' }), placement.bands.map((band) => (_jsx(View, { style: [band.style, { opacity: band.opacity }], children: _jsx(Gradient, { stops: BAND_STOPS[band.name] }) }, band.name)))] }) }) }, `${k}:${side}`));
206
+ }
207
+ }
208
+ const tint = COVER_THEMES[book.theme ?? 'classic'].stage;
209
+ return (_jsx(BookLookProvider, { paper: book.paper, frame: book.frame, fonts: fonts, children: _jsxs(View, { style: [styles.root, { backgroundColor: background ?? tint[2] }, style], children: [!background && _jsx(StageGlow, { colors: tint }), _jsx(View, { style: styles.stage, onLayout: (e) => setStage({ width: e.nativeEvent.layout.width, height: e.nativeEvent.layout.height }), pointerEvents: "box-only", ...responder.panHandlers, children: pageW > 0 && (_jsxs(Animated.View, { style: { width: pageW * across, height: pageH, transform: [{ translateX: shiftValue }] }, pointerEvents: "none", children: [_jsx(View, { style: [
210
+ styles.shadow,
211
+ {
212
+ left: layout.spread && reader.atStart && settled ? '52%' : '2%',
213
+ right: layout.spread && reader.atEnd && settled ? '52%' : '2%',
214
+ },
215
+ ] }), _jsx(View, { style: [styles.spine, { left: layout.spread ? pageW : 0 }], children: faces })] })) }), controls && (_jsxs(Animated.View, { style: [styles.bar, styles.top, { opacity: chrome }], pointerEvents: chromeShown ? 'box-none' : 'none', children: [_jsx(Gradient, { to: "bottom", stops: [
216
+ { offset: 0, color: '#000', opacity: 0.6 },
217
+ { offset: 1, color: '#000', opacity: 0 },
218
+ ] }), _jsxs(View, { style: styles.group, children: [topLeft, _jsxs(View, { style: styles.heading, children: [_jsx(Text, { style: styles.title, numberOfLines: 1, children: book.title }), book.author ? (_jsx(Text, { style: styles.byline, numberOfLines: 1, children: labels.byline(book.author) })) : null] })] }), _jsx(View, { style: styles.group, children: topRight })] })), showPages && thumbnails && (_jsx(PageStrip, { book: book, layout: layout, flipped: flipped, onPick: (target) => {
219
+ reader.go(target);
220
+ setShowPages(false);
221
+ } })), controls && (_jsxs(Animated.View, { style: [styles.bar, styles.bottom, { opacity: chrome }], pointerEvents: chromeShown ? 'box-none' : 'none', children: [_jsx(Gradient, { to: "top", stops: [
222
+ { offset: 0, color: '#000', opacity: 0.6 },
223
+ { offset: 1, color: '#000', opacity: 0 },
224
+ ] }), _jsx(RoundButton, { label: labels.previous, disabled: reader.atStart, onPress: () => reader.prev(), icon: "M15 6l-6 6 6 6" }), _jsx(Pressable, { onPress: () => thumbnails && setShowPages((s) => !s), style: styles.pages, role: "button", "aria-expanded": thumbnails ? showPages : undefined, children: _jsx(Text, { style: styles.pagesText, children: reader.label }) }), _jsx(RoundButton, { label: labels.next, disabled: reader.atEnd, onPress: () => reader.next(), icon: "M9 6l6 6-6 6" })] })), showRotatePrompt && (_jsxs(View, { style: styles.rotate, children: [_jsxs(Svg, { viewBox: "0 0 64 64", width: 80, height: 80, children: [_jsx(Rect, { x: "20", y: "6", width: "24", height: "52", rx: "4", fill: "none", stroke: "#fff", strokeWidth: 2.5 }), _jsx(Path, { d: "M32 49a2 2 0 1 0 0.01 0", stroke: "#fff", strokeWidth: 4, strokeLinecap: "round" })] }), _jsx(Text, { style: styles.rotateTitle, children: labels.rotateTitle }), _jsx(Text, { style: styles.rotateBody, children: labels.rotateBody }), _jsx(Pressable, { onPress: () => setPortraitChoice('single'), role: "button", children: _jsx(Text, { style: styles.rotateSecondary, children: labels.rotateSingle }) })] }))] }) }));
225
+ });
226
+ /** A soft glow behind the book, tinted by the cover. */
227
+ function StageGlow({ colors }) {
228
+ return (_jsxs(Svg, { style: StyleSheet.absoluteFill, width: "100%", height: "100%", pointerEvents: "none", children: [_jsx(Defs, { children: _jsxs(RadialGradient, { id: "fbStage", cx: "50%", cy: "40%", rx: "70%", ry: "70%", children: [_jsx(Stop, { offset: "0", stopColor: colors[0] }), _jsx(Stop, { offset: "0.55", stopColor: colors[1] }), _jsx(Stop, { offset: "1", stopColor: colors[2] })] }) }), _jsx(Rect, { width: "100%", height: "100%", fill: "url(#fbStage)" })] }));
229
+ }
230
+ /** A strip of every spread (or page) to jump around the book. */
231
+ function PageStrip({ book, layout, flipped, onPick, }) {
232
+ const width = 54;
233
+ return (_jsx(View, { style: styles.strip, children: _jsx(ScrollView, { horizontal: true, contentContainerStyle: styles.stripList, showsHorizontalScrollIndicator: false, children: layout.stops().map((stop) => (_jsx(Pressable, { onPress: () => onPick(stop.flipped), role: "button", "aria-label": `Go to ${BookLayout.describe(stop.faces)}`, style: [styles.stop, stop.flipped === flipped && styles.stopCurrent], children: stop.faces.map((face, i) => (_jsx(FaceContent, { face: face, book: book, width: width, side: i === 0 && stop.faces.length > 1 ? 'left' : 'right' }, i))) }, stop.flipped))) }) }));
234
+ }
235
+ function RoundButton({ label, icon, disabled, onPress }) {
236
+ return (_jsx(Pressable, { onPress: onPress, disabled: disabled, role: "button", "aria-label": label, style: [styles.button, disabled && styles.disabled], children: _jsx(Svg, { viewBox: "0 0 24 24", width: 20, height: 20, children: _jsx(Path, { d: icon, stroke: "#fff", strokeWidth: 2, fill: "none", strokeLinecap: "round", strokeLinejoin: "round" }) }) }));
237
+ }
238
+ const styles = StyleSheet.create({
239
+ root: { flex: 1, overflow: 'hidden', userSelect: 'none' },
240
+ stage: { flex: 1, alignItems: 'center', justifyContent: 'center' },
241
+ spine: { position: 'absolute', top: 0, width: 0, height: 0 },
242
+ shadow: { position: 'absolute', bottom: -10, height: 20, borderRadius: 999, backgroundColor: 'rgba(0,0,0,0.25)' },
243
+ bar: { position: 'absolute', left: 0, right: 0, flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 16 },
244
+ top: { top: 0, justifyContent: 'space-between', paddingVertical: 12 },
245
+ bottom: { bottom: 0, justifyContent: 'center', paddingTop: 24, paddingBottom: 16 },
246
+ group: { flexDirection: 'row', alignItems: 'center', gap: 8, flexShrink: 1 },
247
+ heading: { flexShrink: 1 },
248
+ title: { color: '#fff', fontSize: 18 },
249
+ byline: { color: 'rgba(255,255,255,0.6)', fontSize: 12 },
250
+ button: { padding: 10, borderRadius: 999, backgroundColor: 'rgba(255,255,255,0.1)' },
251
+ disabled: { opacity: 0.3 },
252
+ pages: {
253
+ minWidth: 160,
254
+ alignItems: 'center',
255
+ paddingVertical: 8,
256
+ paddingHorizontal: 16,
257
+ borderRadius: 999,
258
+ backgroundColor: 'rgba(255,255,255,0.1)',
259
+ },
260
+ pagesText: { color: 'rgba(255,255,255,0.9)', fontSize: 14, fontVariant: ['tabular-nums'] },
261
+ strip: { position: 'absolute', left: 16, right: 16, bottom: 80, borderRadius: 16, backgroundColor: 'rgba(0,0,0,0.6)' },
262
+ stripList: { gap: 12, padding: 12 },
263
+ stop: { flexDirection: 'row', borderWidth: 2, borderColor: 'transparent', opacity: 0.8 },
264
+ stopCurrent: { borderColor: '#fcd34d', opacity: 1 },
265
+ rotate: {
266
+ position: 'absolute',
267
+ top: 0,
268
+ right: 0,
269
+ bottom: 0,
270
+ left: 0,
271
+ alignItems: 'center',
272
+ justifyContent: 'center',
273
+ gap: 16,
274
+ paddingHorizontal: 32,
275
+ backgroundColor: 'rgba(0,0,0,0.85)',
276
+ },
277
+ rotateTitle: { color: '#fff', fontSize: 24, textAlign: 'center' },
278
+ rotateBody: { color: 'rgba(255,255,255,0.7)', fontSize: 14, textAlign: 'center' },
279
+ rotateSecondary: { color: 'rgba(255,255,255,0.8)', fontSize: 14, textDecorationLine: 'underline' },
280
+ });
@@ -0,0 +1,14 @@
1
+ import { type StyleProp, type ViewStyle } from 'react-native';
2
+ export interface GradientStop {
3
+ offset: number;
4
+ color: string;
5
+ opacity?: number;
6
+ }
7
+ /** A linear gradient filling its box, drawn with react-native-svg so it needs no other native module. */
8
+ export declare function Gradient({ stops, to, style, }: {
9
+ stops: GradientStop[];
10
+ /** Where the last stop lies. */
11
+ to?: 'right' | 'left' | 'bottom' | 'top';
12
+ style?: StyleProp<ViewStyle>;
13
+ }): import("react").JSX.Element;
14
+ /** Stops for a colour given as `rgba(r,g,b,a)`, split so svg gets the alpha separately. */
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId } from 'react';
3
+ import { StyleSheet } from 'react-native';
4
+ import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg';
5
+ /** A linear gradient filling its box, drawn with react-native-svg so it needs no other native module. */
6
+ export function Gradient({ stops, to = 'right', style = StyleSheet.absoluteFill, }) {
7
+ const id = `g${useId().replace(/[^a-zA-Z0-9]/g, '')}`;
8
+ const [x1, y1, x2, y2] = { right: [0, 0, 1, 0], left: [1, 0, 0, 0], bottom: [0, 0, 0, 1], top: [0, 1, 0, 0] }[to];
9
+ return (_jsxs(Svg, { style: style, width: "100%", height: "100%", pointerEvents: "none", children: [_jsx(Defs, { children: _jsx(LinearGradient, { id: id, x1: x1, y1: y1, x2: x2, y2: y2, children: stops.map((stop, i) => (_jsx(Stop, { offset: stop.offset, stopColor: stop.color, stopOpacity: stop.opacity ?? 1 }, i))) }) }), _jsx(Rect, { width: "100%", height: "100%", fill: `url(#${id})` })] }));
10
+ }
@@ -0,0 +1,15 @@
1
+ import { type FlipbookPhoto, type FrameStyle } from '@toneflix/flipbook-core';
2
+ /**
3
+ * A photo printed on a page inside its frame. It fills its parent, or with `fit="contain"` keeps the
4
+ * photo's shape and centres it. `unit` is 1% of the page's width, so frames look the same at any size.
5
+ */
6
+ export interface PrintedPhotoProps {
7
+ photo: FlipbookPhoto | null;
8
+ frame: FrameStyle;
9
+ /** 1% of the page width. */
10
+ unit: number;
11
+ fit?: 'cover' | 'contain';
12
+ /** False draws a placeholder instead of loading the image. */
13
+ show?: boolean;
14
+ }
15
+ export declare function PrintedPhoto({ photo, frame, unit, fit, show }: PrintedPhotoProps): import("react").JSX.Element;
@@ -0,0 +1,168 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useId, useState } from 'react';
3
+ import { Image, StyleSheet, View } from 'react-native';
4
+ import Svg, { ClipPath, Defs, Ellipse, G, Image as SvgImage, LinearGradient as SvgGradient, Path, Pattern, Polygon, Rect, Circle, Stop, } from 'react-native-svg';
5
+ import { BANDED_FRAMES } from '@toneflix/flipbook-core';
6
+ import { Gradient } from './Gradient';
7
+ import { ink, usePaper } from './look';
8
+ const BANDS = BANDED_FRAMES;
9
+ /** The space inside the frame line (pad) and the frame line itself, in page units. Mirrors the web's .frame-* styles. */
10
+ function measure(frame, unit) {
11
+ const band = BANDS[frame];
12
+ if (band)
13
+ return { pad: (band + 2.6) * unit, line: 0 };
14
+ switch (frame) {
15
+ case 'hairline':
16
+ return { pad: 1.6 * unit, line: Math.max(1, 0.22 * unit) };
17
+ case 'double':
18
+ return { pad: 1.4 * unit, line: Math.max(3, 0.7 * unit) };
19
+ case 'mat':
20
+ return { pad: 4.5 * unit, line: Math.max(1, 0.7 * unit) };
21
+ case 'corners':
22
+ return { pad: 0.7 * unit, line: 0 };
23
+ case 'deckle':
24
+ return { pad: 2.6 * unit, line: 0 };
25
+ case 'plate':
26
+ return { pad: 3.2 * unit, line: 0 };
27
+ case 'oval':
28
+ return { pad: 1.5 * unit, line: Math.max(1, 0.22 * unit) };
29
+ case 'botanical':
30
+ return { pad: 5 * unit, line: 0 };
31
+ default:
32
+ return { pad: 0, line: 0 };
33
+ }
34
+ }
35
+ export function PrintedPhoto({ photo, frame, unit, fit = 'cover', show = true }) {
36
+ const look = usePaper();
37
+ const [box, setBox] = useState(null);
38
+ const { pad, line } = measure(frame, unit);
39
+ const edge = pad + line;
40
+ const ratio = photo?.width && photo.height ? photo.width / photo.height : 4 / 3;
41
+ let width = box?.width ?? 0;
42
+ let height = box?.height ?? 0;
43
+ if (box && fit === 'contain') {
44
+ width = Math.min(box.width, (box.height - 2 * edge) * ratio + 2 * edge);
45
+ height = (width - 2 * edge) / ratio + 2 * edge;
46
+ }
47
+ const inner = { left: edge, top: edge, width: Math.max(0, width - 2 * edge), height: Math.max(0, height - 2 * edge) };
48
+ return (_jsx(View, { style: styles.box, onLayout: (event) => setBox(event.nativeEvent.layout), children: box && (_jsxs(View, { style: { width, height }, children: [_jsx(Backing, { frame: frame, look: look, unit: unit, width: width, height: height, line: line }), frame === 'oval' ? (_jsx(OvalPhoto, { photo: show ? photo : null, look: look, rect: inner, width: width, height: height, line: line })) : (_jsxs(View, { style: [styles.abs, inner], children: [photo && show ? (_jsx(Image, { source: { uri: photo.src }, style: StyleSheet.absoluteFill, resizeMode: "cover", "aria-label": photo.alt ?? undefined })) : (_jsx(View, { style: [StyleSheet.absoluteFill, { backgroundColor: ink(look, 0.15) }] })), frame === 'feather' && _jsx(Feather, { look: look })] })), _jsx(Trim, { frame: frame, look: look, unit: unit, width: width, height: height, rect: inner })] })) }));
49
+ }
50
+ /** What is printed under the photo: rules, mats, tints and bands. */
51
+ function Backing({ frame, look, unit, width, height, line, }) {
52
+ const band = BANDS[frame];
53
+ if (band)
54
+ return _jsx(Band, { frame: frame, look: look, unit: unit, band: band * unit, width: width, height: height });
55
+ switch (frame) {
56
+ case 'hairline':
57
+ return _jsx(View, { style: [StyleSheet.absoluteFill, { borderWidth: line, borderColor: ink(look, 0.7) }] });
58
+ case 'double': {
59
+ const rule = line / 3;
60
+ const color = ink(look, 0.75);
61
+ return (_jsxs(_Fragment, { children: [_jsx(View, { style: [StyleSheet.absoluteFill, { borderWidth: rule, borderColor: color }] }), _jsx(View, { style: [
62
+ styles.abs,
63
+ { top: rule * 2, left: rule * 2, right: rule * 2, bottom: rule * 2, borderWidth: rule, borderColor: color },
64
+ ] })] }));
65
+ }
66
+ case 'mat':
67
+ return (_jsx(View, { style: [StyleSheet.absoluteFill, { backgroundColor: look.frameTint, borderWidth: line, borderColor: ink(look, 0.85) }] }));
68
+ case 'plate': {
69
+ const press = 0.2 * unit;
70
+ return (_jsx(View, { style: [
71
+ StyleSheet.absoluteFill,
72
+ {
73
+ backgroundColor: look.plateTint,
74
+ borderTopWidth: press,
75
+ borderLeftWidth: press,
76
+ borderBottomWidth: press,
77
+ borderRightWidth: press,
78
+ borderTopColor: look.plateLow,
79
+ borderLeftColor: look.plateLow,
80
+ borderBottomColor: look.plateHigh,
81
+ borderRightColor: look.plateHigh,
82
+ },
83
+ ] }));
84
+ }
85
+ case 'deckle':
86
+ return _jsx(Deckle, { look: look, scallop: 1.6 * unit, width: width, height: height });
87
+ case 'oval':
88
+ return (_jsx(Svg, { width: width, height: height, style: styles.abs, children: _jsx(Ellipse, { cx: width / 2, cy: height / 2, rx: width / 2 - line / 2, ry: height / 2 - line / 2, stroke: ink(look, 0.7), strokeWidth: line, fill: "none" }) }));
89
+ default:
90
+ return null;
91
+ }
92
+ }
93
+ /** What is printed over or around the photo's edge: keylines, corners and sprigs. */
94
+ function Trim({ frame, look, unit, width, height, rect, }) {
95
+ if (BANDS[frame])
96
+ return _jsx(Keyline, { rect: rect, weight: Math.max(1, 0.2 * unit), color: ink(look, 0.22) });
97
+ switch (frame) {
98
+ case 'mat':
99
+ return _jsx(Keyline, { rect: rect, weight: Math.max(1, 0.25 * unit), color: ink(look, 0.18) });
100
+ case 'botanical':
101
+ return (_jsxs(_Fragment, { children: [_jsx(Keyline, { rect: rect, weight: Math.max(1, 0.18 * unit), gap: 1.6 * unit, color: ink(look, 0.6) }), _jsx(Sprig, { color: ink(look, 0.8), unit: unit, style: { top: -1.8 * unit, left: 1.4 * unit } }), _jsx(Sprig, { color: ink(look, 0.8), unit: unit, style: { bottom: -1.8 * unit, right: 1.4 * unit, transform: [{ rotate: '180deg' }] } })] }));
102
+ case 'corners': {
103
+ const size = 5.5 * unit;
104
+ return (_jsx(Svg, { width: width, height: height, style: styles.abs, children: _jsxs(G, { fill: ink(look, 0.82), children: [_jsx(Polygon, { points: `0,0 ${size},0 0,${size}` }), _jsx(Polygon, { points: `${width - size},0 ${width},0 ${width},${size}` }), _jsx(Polygon, { points: `0,${height - size} ${size},${height} 0,${height}` }), _jsx(Polygon, { points: `${width},${height - size} ${width},${height} ${width - size},${height}` })] }) }));
105
+ }
106
+ default:
107
+ return null;
108
+ }
109
+ }
110
+ /** A printed rule just outside the photo, `gap` away from it. */
111
+ function Keyline({ rect, weight, color, gap = 0 }) {
112
+ const out = gap + weight;
113
+ return (_jsx(View, { style: [
114
+ styles.abs,
115
+ {
116
+ left: rect.left - out,
117
+ top: rect.top - out,
118
+ width: rect.width + out * 2,
119
+ height: rect.height + out * 2,
120
+ borderWidth: weight,
121
+ borderColor: color,
122
+ },
123
+ ] }));
124
+ }
125
+ /** A tint with a scalloped edge, like an old deckled print. */
126
+ function Deckle({ look, scallop, width, height }) {
127
+ const r = scallop / 2;
128
+ const across = Array.from({ length: Math.ceil(width / scallop) }, (_, i) => r + i * scallop);
129
+ const down = Array.from({ length: Math.ceil(height / scallop) }, (_, i) => r + i * scallop);
130
+ return (_jsx(Svg, { width: width, height: height, style: styles.abs, children: _jsxs(G, { opacity: 0.11, fill: ink(look), children: [_jsx(Rect, { x: r, y: r, width: width - scallop, height: height - scallop }), across.map((x) => (_jsxs(G, { children: [_jsx(Circle, { cx: x, cy: r, r: r }), _jsx(Circle, { cx: x, cy: height - r, r: r })] }, `x${x}`))), down.map((y) => (_jsxs(G, { children: [_jsx(Circle, { cx: r, cy: y, r: r }), _jsx(Circle, { cx: width - r, cy: y, r: r })] }, `y${y}`)))] }) }));
131
+ }
132
+ /** A mat with a patterned band printed around it. */
133
+ function Band({ frame, look, unit, band, width, height, }) {
134
+ const id = useId().replace(/[^a-zA-Z0-9]/g, '');
135
+ const ring = `M0 0H${width}V${height}H0Z M${band} ${band}V${height - band}H${width - band}V${band}Z`;
136
+ const gilt = Math.max(1, 0.35 * unit);
137
+ return (_jsxs(_Fragment, { children: [_jsx(View, { style: [StyleSheet.absoluteFill, { backgroundColor: look.frameTint }] }), _jsxs(Svg, { width: width, height: height, style: styles.abs, children: [_jsxs(Defs, { children: [_jsxs(SvgGradient, { id: `wash${id}`, x1: "0", y1: "0", x2: "1", y2: "1", children: [_jsx(Stop, { offset: "0", stopColor: "#f0a993" }), _jsx(Stop, { offset: "0.3", stopColor: "#f3d27c" }), _jsx(Stop, { offset: "0.62", stopColor: "#9fd3bf" }), _jsx(Stop, { offset: "1", stopColor: "#8fb6e2" })] }), _jsx(Pattern, { id: `arcs${id}`, patternUnits: "userSpaceOnUse", width: 2.4 * unit, height: 1.3 * unit, children: [
138
+ [1.2, '#7ea7d8'],
139
+ [0.9, '#79c2a2'],
140
+ [0.6, '#f2c95c'],
141
+ [0.3, '#e9967a'],
142
+ ].map(([radius, color]) => (_jsx(Circle, { cx: 1.2 * unit, cy: 1.3 * unit, r: radius * unit, fill: color }, color))) }), [45, -45].map((angle) => (_jsx(Pattern, { id: `hatch${angle}${id}`, patternUnits: "userSpaceOnUse", width: 0.8 * unit, height: 0.8 * unit, patternTransform: `rotate(${angle})`, children: _jsx(Rect, { width: 0.22 * unit, height: 0.8 * unit, fill: "rgba(180,138,50,0.75)" }) }, angle))), _jsxs(Pattern, { id: `grain${id}`, patternUnits: "userSpaceOnUse", width: 1.5 * unit, height: 4 * unit, patternTransform: "rotate(8)", children: [_jsx(Rect, { width: 0.12 * unit, height: 4 * unit, fill: "rgba(110,70,30,0.4)" }), _jsx(Rect, { x: 0.55 * unit, width: 0.25 * unit, height: 4 * unit, fill: "rgba(110,70,30,0.22)" })] })] }), frame === 'watercolor' && _jsx(Path, { d: ring, fillRule: "evenodd", fill: `url(#wash${id})`, opacity: 0.7 }), frame === 'arcs' && _jsx(Path, { d: ring, fillRule: "evenodd", fill: `url(#arcs${id})`, opacity: 0.8 }), frame === 'gilt' && (_jsxs(_Fragment, { children: [_jsx(Path, { d: ring, fillRule: "evenodd", fill: "rgba(214,178,92,0.45)" }), _jsx(Path, { d: ring, fillRule: "evenodd", fill: `url(#hatch45${id})` }), _jsx(Path, { d: ring, fillRule: "evenodd", fill: `url(#hatch-45${id})` }), _jsx(Rect, { x: gilt / 2, y: gilt / 2, width: width - gilt, height: height - gilt, stroke: "#b48a32", strokeWidth: gilt, fill: "none" })] })), frame === 'woodgrain' && (_jsxs(G, { opacity: 0.85, children: [_jsx(Path, { d: ring, fillRule: "evenodd", fill: "#cfa874" }), _jsx(Path, { d: ring, fillRule: "evenodd", fill: `url(#grain${id})` })] }))] })] }));
143
+ }
144
+ /** The photo cut to an oval. */
145
+ function OvalPhoto({ photo, look, rect, width, height, }) {
146
+ const id = `oval${useId().replace(/[^a-zA-Z0-9]/g, '')}`;
147
+ return (_jsxs(Svg, { width: width, height: height, style: styles.abs, children: [_jsx(Defs, { children: _jsx(ClipPath, { id: id, children: _jsx(Ellipse, { cx: width / 2, cy: height / 2, rx: rect.width / 2, ry: rect.height / 2 }) }) }), photo ? (_jsx(SvgImage, { href: { uri: photo.src }, x: rect.left, y: rect.top, width: rect.width, height: rect.height, preserveAspectRatio: "xMidYMid slice", clipPath: `url(#${id})` })) : (_jsx(Ellipse, { cx: width / 2, cy: height / 2, rx: rect.width / 2, ry: rect.height / 2, fill: ink(look, 0.15) }))] }));
148
+ }
149
+ /** Fades the photo's edges into the paper. */
150
+ function Feather({ look }) {
151
+ const stops = [
152
+ { offset: 0, color: look.background },
153
+ { offset: 0.08, color: look.background, opacity: 0 },
154
+ { offset: 0.92, color: look.background, opacity: 0 },
155
+ { offset: 1, color: look.background },
156
+ ];
157
+ return (_jsxs(_Fragment, { children: [_jsx(Gradient, { stops: stops, to: "right" }), _jsx(Gradient, { stops: stops, to: "bottom" })] }));
158
+ }
159
+ /** Leaves along a stem, alternating sides and shrinking toward the tip. */
160
+ const LEAVES = [14, 24, 34, 44, 54, 64, 74].map((x, i) => ({ x, angle: i % 2 ? 38 : -38, length: 26 - i * 2 }));
161
+ /** A sprig of leaves drawn in ink like a pen sketch, its stem along the frame rule. */
162
+ function Sprig({ color, unit, style }) {
163
+ return (_jsxs(Svg, { viewBox: "0 0 100 40", width: 26 * unit, height: 10.4 * unit, style: [styles.abs, style], fill: "none", stroke: color, strokeLinecap: "round", children: [_jsx(Path, { d: "M4 20 Q 50 17, 92 20", strokeWidth: 1.2 }), LEAVES.map(({ x, angle, length }) => (_jsxs(G, { transform: `translate(${x} 20) rotate(${angle}) scale(${length / 20})`, children: [_jsx(Path, { d: "M0 0 C 5 -5, 14 -5, 20 0 C 14 5, 5 5, 0 0 Z", strokeWidth: 1 }), _jsx(Path, { d: "M2 0 L 16 0", strokeWidth: 0.6 })] }, x)))] }));
164
+ }
165
+ const styles = StyleSheet.create({
166
+ box: { position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, alignItems: 'center', justifyContent: 'center' },
167
+ abs: { position: 'absolute' },
168
+ });