@toneflix/flipbook-native 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +23 -2
- package/assets/textures/grain-charcoal@2x.webp +0 -0
- package/assets/textures/grain-fine@2x.webp +0 -0
- package/assets/textures/grain-kraft@2x.webp +0 -0
- package/assets/textures/paper-charcoal@2x.webp +0 -0
- package/assets/textures/paper-fine@2x.webp +0 -0
- package/assets/textures/paper-kraft@2x.webp +0 -0
- package/lib/Flipbook.d.ts +53 -0
- package/lib/Flipbook.js +280 -0
- package/lib/Gradient.d.ts +14 -0
- package/lib/Gradient.js +10 -0
- package/lib/PrintedPhoto.d.ts +15 -0
- package/lib/PrintedPhoto.js +168 -0
- package/lib/geometry.d.ts +44 -0
- package/lib/geometry.js +114 -0
- package/lib/index.d.ts +8 -0
- package/lib/index.js +8 -0
- package/lib/look.d.ts +53 -0
- package/lib/look.js +68 -0
- package/lib/orientation.d.ts +5 -0
- package/lib/orientation.js +25 -0
- package/lib/pages.d.ts +59 -0
- package/lib/pages.js +135 -0
- package/package.json +50 -3
- package/src/Flipbook.tsx +600 -0
- package/src/Gradient.tsx +39 -0
- package/src/PrintedPhoto.tsx +456 -0
- package/src/geometry.ts +147 -0
- package/src/index.ts +19 -0
- package/src/look.tsx +110 -0
- package/src/orientation.ts +32 -0
- package/src/pages.tsx +440 -0
- package/src/require.d.ts +1 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type ActiveTurn, type TurnFrame } from '@toneflix/flipbook-core';
|
|
2
|
+
import type { ViewStyle } from 'react-native';
|
|
3
|
+
export type Role = 'right' | 'left' | 'turning' | 'flap' | 'spare';
|
|
4
|
+
/** A strip of light or shadow laid along the fold, inside one face. */
|
|
5
|
+
export interface Band {
|
|
6
|
+
name: 'under' | 'bend' | 'curl';
|
|
7
|
+
style: ViewStyle;
|
|
8
|
+
opacity: number;
|
|
9
|
+
}
|
|
10
|
+
/** Where one face sits: a clip window, a frame undoing the window's rotation, and the face itself. */
|
|
11
|
+
export interface FacePlacement {
|
|
12
|
+
clip: ViewStyle;
|
|
13
|
+
frame: ViewStyle;
|
|
14
|
+
face: ViewStyle;
|
|
15
|
+
bands: Band[];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Lays out the faces of a book for React Native, which can't clip to a polygon. Coordinates are
|
|
19
|
+
* page pixels from the top of the spine. A folded page is clipped by a large window rotated to
|
|
20
|
+
* line up with the fold, inside which the faces are placed back in spine coordinates, so the
|
|
21
|
+
* same three nested views serve every face whether it's turning or lying still.
|
|
22
|
+
*/
|
|
23
|
+
export declare class FoldLayout {
|
|
24
|
+
readonly width: number;
|
|
25
|
+
readonly height: number;
|
|
26
|
+
readonly turn: ActiveTurn | null;
|
|
27
|
+
readonly frame: TurnFrame | null;
|
|
28
|
+
readonly shading: number;
|
|
29
|
+
private readonly side;
|
|
30
|
+
private readonly rest;
|
|
31
|
+
private readonly window;
|
|
32
|
+
private readonly fold;
|
|
33
|
+
private readonly dir;
|
|
34
|
+
private readonly reflection;
|
|
35
|
+
constructor(width: number, height: number, turn: ActiveTurn | null, frame: TurnFrame | null, shading?: number);
|
|
36
|
+
/** Spine coordinates of the fold's centre and its normal toward the folded part. */
|
|
37
|
+
private get line();
|
|
38
|
+
/** Places a face given its role, which side of the leaf it is, and whether it lies on the left. */
|
|
39
|
+
place(role: Role, side: 'front' | 'back', onLeft: boolean): FacePlacement;
|
|
40
|
+
/** Light and shadow along the fold that fall on this face. */
|
|
41
|
+
private bands;
|
|
42
|
+
/** A stiff cover swings on the spine instead of folding. */
|
|
43
|
+
private hinge;
|
|
44
|
+
}
|
package/lib/geometry.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { Affine, Fold } from '@toneflix/flipbook-core';
|
|
2
|
+
/** Band widths as shares of the page width. */
|
|
3
|
+
const BAND_WIDTH = { under: 0.3, bend: 0.1, curl: 1 };
|
|
4
|
+
const styleOf = (transform, width, height) => {
|
|
5
|
+
const { left, top, angle, mirror } = transform.place(width, height);
|
|
6
|
+
return {
|
|
7
|
+
position: 'absolute',
|
|
8
|
+
left,
|
|
9
|
+
top,
|
|
10
|
+
width,
|
|
11
|
+
height,
|
|
12
|
+
transform: mirror ? [{ rotate: `${angle}rad` }, { scaleX: -1 }] : [{ rotate: `${angle}rad` }],
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Lays out the faces of a book for React Native, which can't clip to a polygon. Coordinates are
|
|
17
|
+
* page pixels from the top of the spine. A folded page is clipped by a large window rotated to
|
|
18
|
+
* line up with the fold, inside which the faces are placed back in spine coordinates, so the
|
|
19
|
+
* same three nested views serve every face whether it's turning or lying still.
|
|
20
|
+
*/
|
|
21
|
+
export class FoldLayout {
|
|
22
|
+
width;
|
|
23
|
+
height;
|
|
24
|
+
turn;
|
|
25
|
+
frame;
|
|
26
|
+
shading;
|
|
27
|
+
side;
|
|
28
|
+
rest;
|
|
29
|
+
window;
|
|
30
|
+
fold;
|
|
31
|
+
dir;
|
|
32
|
+
reflection;
|
|
33
|
+
constructor(width, height, turn, frame, shading = 1) {
|
|
34
|
+
this.width = width;
|
|
35
|
+
this.height = height;
|
|
36
|
+
this.turn = turn;
|
|
37
|
+
this.frame = frame;
|
|
38
|
+
this.shading = shading;
|
|
39
|
+
this.side = 4 * Math.max(width, height);
|
|
40
|
+
this.rest = Affine.translate(-2 * width, -height);
|
|
41
|
+
this.dir = turn?.dir ?? 1;
|
|
42
|
+
this.fold = turn && frame && !turn.stiff ? new Fold(width, height, frame.origin, frame.point) : null;
|
|
43
|
+
this.window = this.rest;
|
|
44
|
+
this.reflection = new Affine();
|
|
45
|
+
if (!this.fold)
|
|
46
|
+
return;
|
|
47
|
+
const { center, normal } = this.fold;
|
|
48
|
+
const c = { x: this.dir * center.x, y: center.y };
|
|
49
|
+
const n = { x: this.dir * normal.x, y: normal.y };
|
|
50
|
+
this.window = Affine.translate(c.x, c.y)
|
|
51
|
+
.after(Affine.rotate(Math.atan2(n.y, n.x)))
|
|
52
|
+
.after(Affine.translate(-this.side, -this.side / 2));
|
|
53
|
+
this.reflection = Affine.reflect(c, n);
|
|
54
|
+
}
|
|
55
|
+
/** Spine coordinates of the fold's centre and its normal toward the folded part. */
|
|
56
|
+
get line() {
|
|
57
|
+
const { center, normal } = this.fold;
|
|
58
|
+
return { c: { x: this.dir * center.x, y: center.y }, n: { x: this.dir * normal.x, y: normal.y } };
|
|
59
|
+
}
|
|
60
|
+
/** Places a face given its role, which side of the leaf it is, and whether it lies on the left. */
|
|
61
|
+
place(role, side, onLeft) {
|
|
62
|
+
const { width, height, fold } = this;
|
|
63
|
+
const turning = role === 'turning' || role === 'flap';
|
|
64
|
+
const clipped = !!fold && turning;
|
|
65
|
+
const window = clipped ? this.window : this.rest;
|
|
66
|
+
const windowSize = clipped ? { w: this.side, h: this.side } : { w: 4 * width, h: 3 * height };
|
|
67
|
+
const toFrame = window.invert().after(Affine.translate(-width, 0));
|
|
68
|
+
let face = Affine.translate(onLeft ? -width : 0, 0);
|
|
69
|
+
if (fold && role === 'flap') {
|
|
70
|
+
const leaf = side === 'back' ? new Affine(-1, 0, 0, 1, width, 0) : new Affine();
|
|
71
|
+
face = this.reflection.after(Affine.scale(this.dir, 1)).after(leaf);
|
|
72
|
+
}
|
|
73
|
+
const placement = {
|
|
74
|
+
clip: { ...styleOf(window, windowSize.w, windowSize.h), overflow: clipped ? 'hidden' : 'visible' },
|
|
75
|
+
frame: styleOf(toFrame, 2 * width, height),
|
|
76
|
+
face: { ...styleOf(Affine.translate(width, 0).after(face), width, height), overflow: 'hidden' },
|
|
77
|
+
bands: [],
|
|
78
|
+
};
|
|
79
|
+
if (fold)
|
|
80
|
+
placement.bands = this.bands(role, face);
|
|
81
|
+
if (this.turn?.stiff && turning)
|
|
82
|
+
placement.face = this.hinge(side, placement.face);
|
|
83
|
+
return placement;
|
|
84
|
+
}
|
|
85
|
+
/** Light and shadow along the fold that fall on this face. */
|
|
86
|
+
bands(role, face) {
|
|
87
|
+
const strength = this.fold.shade * this.shading;
|
|
88
|
+
const band = (name, into) => {
|
|
89
|
+
const { c, n } = this.line;
|
|
90
|
+
const m = { x: into * n.x, y: into * n.y };
|
|
91
|
+
const d = { x: -n.y, y: n.x };
|
|
92
|
+
const width = this.width * BAND_WIDTH[name];
|
|
93
|
+
const height = this.side;
|
|
94
|
+
const strip = new Affine(m.x, m.y, d.x, d.y, c.x - (height / 2) * d.x, c.y - (height / 2) * d.y);
|
|
95
|
+
return { name, style: styleOf(face.invert().after(strip), width, height), opacity: strength };
|
|
96
|
+
};
|
|
97
|
+
if (role === 'turning')
|
|
98
|
+
return [band('bend', -1)];
|
|
99
|
+
if (role === 'flap')
|
|
100
|
+
return [band('curl', -1)];
|
|
101
|
+
const revealed = this.turn && (this.dir === 1 ? role === 'right' : role === 'left');
|
|
102
|
+
return revealed ? [band('under', 1)] : [];
|
|
103
|
+
}
|
|
104
|
+
/** A stiff cover swings on the spine instead of folding. */
|
|
105
|
+
hinge(side, style) {
|
|
106
|
+
const { width, frame } = this;
|
|
107
|
+
const angle = frame ? Fold.hinge(frame.origin, frame.point, this.dir) : 0;
|
|
108
|
+
const swing = [{ translateX: -width / 2 }, { perspective: Math.max(1800, width * 5) }, { rotateY: `${angle}deg` }];
|
|
109
|
+
const transform = side === 'front'
|
|
110
|
+
? [...swing, { translateX: width / 2 }]
|
|
111
|
+
: [...swing, { translateX: width }, { rotateY: '180deg' }, { translateX: width / 2 }];
|
|
112
|
+
return { ...style, transform, backfaceVisibility: 'hidden' };
|
|
113
|
+
}
|
|
114
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from '@toneflix/flipbook-core';
|
|
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';
|
|
4
|
+
export { PrintedPhoto, type PrintedPhotoProps } from './PrintedPhoto';
|
|
5
|
+
export { BookLookProvider, useBookLook, usePaper, useSerif, ink, PAPERS, type BookLook, type BookFonts, type PaperLook } from './look';
|
|
6
|
+
export { Gradient, type GradientStop } from './Gradient';
|
|
7
|
+
export { FoldLayout } from './geometry';
|
|
8
|
+
export { lockLandscape } from './orientation';
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from '@toneflix/flipbook-core';
|
|
2
|
+
export { Flipbook } from './Flipbook';
|
|
3
|
+
export { PageView, Paper, CoverView, BackCoverView, TitlePageView, ColophonView, BlankPageView, PAGE_RATIO, } from './pages';
|
|
4
|
+
export { PrintedPhoto } from './PrintedPhoto';
|
|
5
|
+
export { BookLookProvider, useBookLook, usePaper, useSerif, ink, PAPERS } from './look';
|
|
6
|
+
export { Gradient } from './Gradient';
|
|
7
|
+
export { FoldLayout } from './geometry';
|
|
8
|
+
export { lockLandscape } from './orientation';
|
package/lib/look.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import { type ImageSourcePropType } from 'react-native';
|
|
3
|
+
import type { FrameStyle, PaperColor } from '@toneflix/flipbook-core';
|
|
4
|
+
/** How one paper colour prints: its ink, the tints frames mix with it, and its texture tiles. Mirrors the web's .fb-paper-* styles. */
|
|
5
|
+
export interface PaperLook {
|
|
6
|
+
background: string;
|
|
7
|
+
/** Ink as r, g, b, so frames can print it at any strength. */
|
|
8
|
+
ink: [number, number, number];
|
|
9
|
+
frameTint: string;
|
|
10
|
+
plateTint: string;
|
|
11
|
+
plateLow: string;
|
|
12
|
+
plateHigh: string;
|
|
13
|
+
texture: ImageSourcePropType;
|
|
14
|
+
textureOpacity: number;
|
|
15
|
+
grain: ImageSourcePropType;
|
|
16
|
+
grainOpacity: number;
|
|
17
|
+
/** Soft light from the top left, and the darker edges of the sheet. */
|
|
18
|
+
light: string;
|
|
19
|
+
vignette: string;
|
|
20
|
+
}
|
|
21
|
+
export declare const PAPERS: Record<PaperColor, PaperLook>;
|
|
22
|
+
/** The ink of a paper at a strength from 0 to 1. */
|
|
23
|
+
export declare const ink: (look: PaperLook, alpha?: number) => string;
|
|
24
|
+
/** Font families for page text. Leave out `serifItalic` to slant `serif` instead. */
|
|
25
|
+
export interface BookFonts {
|
|
26
|
+
serif?: string;
|
|
27
|
+
serifItalic?: string;
|
|
28
|
+
sans?: string;
|
|
29
|
+
}
|
|
30
|
+
export interface BookLook {
|
|
31
|
+
paper: PaperColor;
|
|
32
|
+
frame: FrameStyle;
|
|
33
|
+
fonts: BookFonts;
|
|
34
|
+
}
|
|
35
|
+
/** The paper, default frame and fonts for every page rendered inside. The viewer sets it from the book. */
|
|
36
|
+
export declare function BookLookProvider({ paper, frame, fonts, children }: Partial<BookLook> & {
|
|
37
|
+
children: ReactNode;
|
|
38
|
+
}): import("react").JSX.Element;
|
|
39
|
+
export declare const useBookLook: () => BookLook;
|
|
40
|
+
export declare const usePaper: () => PaperLook;
|
|
41
|
+
/** Text styles for the book's serif face, upright or italic. */
|
|
42
|
+
export declare function useSerif(): {
|
|
43
|
+
upright: {
|
|
44
|
+
fontFamily: string;
|
|
45
|
+
};
|
|
46
|
+
italic: {
|
|
47
|
+
fontFamily: string;
|
|
48
|
+
fontStyle?: undefined;
|
|
49
|
+
} | {
|
|
50
|
+
fontFamily: string;
|
|
51
|
+
fontStyle: "italic";
|
|
52
|
+
};
|
|
53
|
+
};
|
package/lib/look.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useContext } from 'react';
|
|
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') };
|
|
5
|
+
const ivory = {
|
|
6
|
+
background: '#fdf9f0',
|
|
7
|
+
ink: [61, 55, 47],
|
|
8
|
+
frameTint: 'rgba(255,255,255,0.4)',
|
|
9
|
+
plateTint: 'rgba(90,70,40,0.03)',
|
|
10
|
+
plateLow: 'rgba(90,70,40,0.12)',
|
|
11
|
+
plateHigh: 'rgba(255,255,255,0.6)',
|
|
12
|
+
...fine,
|
|
13
|
+
textureOpacity: 0.35,
|
|
14
|
+
grainOpacity: 0.45,
|
|
15
|
+
light: 'rgba(255,255,255,0.5)',
|
|
16
|
+
vignette: 'rgba(110,90,60,0.08)',
|
|
17
|
+
};
|
|
18
|
+
export const PAPERS = {
|
|
19
|
+
ivory,
|
|
20
|
+
white: { ...ivory, background: '#ffffff', frameTint: 'rgba(150,125,90,0.08)' },
|
|
21
|
+
kraft: {
|
|
22
|
+
background: '#d8c4a2',
|
|
23
|
+
ink: [61, 47, 29],
|
|
24
|
+
frameTint: 'rgba(255,248,235,0.35)',
|
|
25
|
+
plateTint: 'rgba(90,70,40,0.03)',
|
|
26
|
+
plateLow: 'rgba(80,50,20,0.16)',
|
|
27
|
+
plateHigh: 'rgba(255,245,225,0.35)',
|
|
28
|
+
texture: require('../assets/textures/paper-kraft@2x.webp'),
|
|
29
|
+
textureOpacity: 0.45,
|
|
30
|
+
grain: require('../assets/textures/grain-kraft@2x.webp'),
|
|
31
|
+
grainOpacity: 0.35,
|
|
32
|
+
light: 'rgba(255,255,255,0.18)',
|
|
33
|
+
vignette: 'rgba(80,50,20,0.12)',
|
|
34
|
+
},
|
|
35
|
+
charcoal: {
|
|
36
|
+
background: '#2a2826',
|
|
37
|
+
ink: [232, 226, 214],
|
|
38
|
+
frameTint: 'rgba(255,255,255,0.07)',
|
|
39
|
+
plateTint: 'rgba(0,0,0,0.08)',
|
|
40
|
+
plateLow: 'rgba(0,0,0,0.35)',
|
|
41
|
+
plateHigh: 'rgba(255,255,255,0.07)',
|
|
42
|
+
texture: require('../assets/textures/paper-charcoal@2x.webp'),
|
|
43
|
+
textureOpacity: 0.25,
|
|
44
|
+
grain: require('../assets/textures/grain-charcoal@2x.webp'),
|
|
45
|
+
grainOpacity: 0.22,
|
|
46
|
+
light: 'rgba(255,255,255,0.05)',
|
|
47
|
+
vignette: 'rgba(0,0,0,0.25)',
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
/** The ink of a paper at a strength from 0 to 1. */
|
|
51
|
+
export const ink = (look, alpha = 1) => `rgba(${look.ink.join(',')},${alpha})`;
|
|
52
|
+
const DEFAULT_SERIF = Platform.select({ ios: 'Georgia', android: 'serif', default: 'Georgia, serif' });
|
|
53
|
+
const BookLookContext = createContext({ paper: 'ivory', frame: 'none', fonts: {} });
|
|
54
|
+
/** The paper, default frame and fonts for every page rendered inside. The viewer sets it from the book. */
|
|
55
|
+
export function BookLookProvider({ paper = 'ivory', frame = 'none', fonts = {}, children }) {
|
|
56
|
+
return _jsx(BookLookContext.Provider, { value: { paper, frame, fonts }, children: children });
|
|
57
|
+
}
|
|
58
|
+
export const useBookLook = () => useContext(BookLookContext);
|
|
59
|
+
export const usePaper = () => PAPERS[useBookLook().paper] ?? ivory;
|
|
60
|
+
/** Text styles for the book's serif face, upright or italic. */
|
|
61
|
+
export function useSerif() {
|
|
62
|
+
const { fonts } = useBookLook();
|
|
63
|
+
const serif = fonts.serif ?? DEFAULT_SERIF;
|
|
64
|
+
return {
|
|
65
|
+
upright: { fontFamily: serif },
|
|
66
|
+
italic: fonts.serifItalic ? { fontFamily: fonts.serifItalic } : { fontFamily: serif, fontStyle: 'italic' },
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** `expo-screen-orientation`, when the app has it installed. */
|
|
2
|
+
const screenOrientation = (() => {
|
|
3
|
+
try {
|
|
4
|
+
return require('expo-screen-orientation');
|
|
5
|
+
}
|
|
6
|
+
catch {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
})();
|
|
10
|
+
/**
|
|
11
|
+
* Locks the screen to landscape and resolves to a function that restores the previous lock, or
|
|
12
|
+
* to null when the screen can't be locked.
|
|
13
|
+
*/
|
|
14
|
+
export async function lockLandscape() {
|
|
15
|
+
if (!screenOrientation)
|
|
16
|
+
return null;
|
|
17
|
+
try {
|
|
18
|
+
const previous = await screenOrientation.getOrientationLockAsync();
|
|
19
|
+
await screenOrientation.lockAsync(screenOrientation.OrientationLock.LANDSCAPE);
|
|
20
|
+
return () => void screenOrientation.lockAsync(previous).catch(() => { });
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
package/lib/pages.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import { type ViewStyle } from 'react-native';
|
|
3
|
+
import { type FlipbookBook, type FlipbookPage } from '@toneflix/flipbook-core';
|
|
4
|
+
/** Pages are 3:4, like the web's. */
|
|
5
|
+
export declare const PAGE_RATIO: number;
|
|
6
|
+
type Side = 'left' | 'right';
|
|
7
|
+
export interface PageViewProps {
|
|
8
|
+
page: FlipbookPage;
|
|
9
|
+
/** Page width; the height follows the 3:4 page shape. */
|
|
10
|
+
width: number;
|
|
11
|
+
number?: number;
|
|
12
|
+
side?: Side;
|
|
13
|
+
showImages?: boolean;
|
|
14
|
+
/** Editor: makes each photo slot pressable. */
|
|
15
|
+
onSlotPress?: (slot: number) => void;
|
|
16
|
+
activeSlot?: number | null;
|
|
17
|
+
/** Editor: colour of the highlighted slot. */
|
|
18
|
+
accent?: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The inside of one book page, `width` wide. Sizes are in units of 1% of the width, so the same
|
|
22
|
+
* page looks right as a thumbnail and full screen.
|
|
23
|
+
*/
|
|
24
|
+
export declare function PageView({ page, width, number, side, showImages, onSlotPress, activeSlot, accent }: PageViewProps): import("react").JSX.Element;
|
|
25
|
+
/** A sheet of the book's paper: texture under the content, grain and page light over it. */
|
|
26
|
+
export declare function Paper({ width, side, children, style }: {
|
|
27
|
+
width: number;
|
|
28
|
+
side?: Side;
|
|
29
|
+
children?: ReactNode;
|
|
30
|
+
style?: ViewStyle;
|
|
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
|
+
/** Inside the front cover: a title page. */
|
|
44
|
+
export declare function TitlePageView({ book, width, side, }: {
|
|
45
|
+
book: Pick<FlipbookBook, 'title' | 'subtitle' | 'author' | 'year'>;
|
|
46
|
+
width: number;
|
|
47
|
+
side?: Side;
|
|
48
|
+
}): import("react").JSX.Element;
|
|
49
|
+
/** Inside the back cover, with the book's closing line. */
|
|
50
|
+
export declare function ColophonView({ book, width, side }: {
|
|
51
|
+
book: Pick<FlipbookBook, 'colophon'>;
|
|
52
|
+
width: number;
|
|
53
|
+
side?: Side;
|
|
54
|
+
}): import("react").JSX.Element;
|
|
55
|
+
export declare function BlankPageView({ width, side }: {
|
|
56
|
+
width: number;
|
|
57
|
+
side?: Side;
|
|
58
|
+
}): import("react").JSX.Element;
|
|
59
|
+
export {};
|
package/lib/pages.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useId } from 'react';
|
|
3
|
+
import { Image, Pressable, StyleSheet, Text, View } from 'react-native';
|
|
4
|
+
import Svg, { Circle, Defs, Path, RadialGradient, Rect, Stop } from 'react-native-svg';
|
|
5
|
+
import { COVER_THEMES, PLACED_LAYOUTS, photoFrame } from '@toneflix/flipbook-core';
|
|
6
|
+
import { Gradient } from './Gradient';
|
|
7
|
+
import { ink, useBookLook, usePaper, useSerif } from './look';
|
|
8
|
+
import { PrintedPhoto } from './PrintedPhoto';
|
|
9
|
+
/** Pages are 3:4, like the web's. */
|
|
10
|
+
export const PAGE_RATIO = 3 / 4;
|
|
11
|
+
/**
|
|
12
|
+
* The inside of one book page, `width` wide. Sizes are in units of 1% of the width, so the same
|
|
13
|
+
* page looks right as a thumbnail and full screen.
|
|
14
|
+
*/
|
|
15
|
+
export function PageView({ page, width, number, side, showImages = true, onSlotPress, activeSlot, accent = '#00afff' }) {
|
|
16
|
+
const look = usePaper();
|
|
17
|
+
const serif = useSerif();
|
|
18
|
+
const unit = width / 100;
|
|
19
|
+
const { layout, caption, photos } = page;
|
|
20
|
+
const bookFrame = useBookLook().frame;
|
|
21
|
+
const placed = PLACED_LAYOUTS[layout];
|
|
22
|
+
const captionStyle = [serif.italic, styles.caption, { fontSize: 3.8 * unit, lineHeight: 5.2 * unit, color: ink(look) }];
|
|
23
|
+
const slot = (index, style, fit = 'cover') => {
|
|
24
|
+
const photo = photos[index] ?? null;
|
|
25
|
+
const frame = photoFrame(page, photo, bookFrame);
|
|
26
|
+
return (_jsxs(Pressable, { disabled: !onSlotPress, onPress: () => onSlotPress?.(index), role: onSlotPress ? 'button' : undefined, "aria-label": onSlotPress ? `Photo slot ${index + 1}` : undefined, style: [style, fit === 'cover' && styles.clip], children: [photo ? (_jsx(PrintedPhoto, { photo: photo, frame: frame, unit: unit, fit: fit, show: showImages })) : (_jsx(View, { style: [
|
|
27
|
+
StyleSheet.absoluteFill,
|
|
28
|
+
styles.empty,
|
|
29
|
+
{ backgroundColor: ink(look, 0.1) },
|
|
30
|
+
onSlotPress && { borderWidth: 0.4 * unit, borderColor: ink(look, 0.3) },
|
|
31
|
+
], children: onSlotPress && _jsx(Text, { style: { fontSize: 3.2 * unit, color: ink(look, 0.7) }, children: "Add a photo" }) })), onSlotPress && activeSlot === index && (_jsx(View, { pointerEvents: "none", style: [StyleSheet.absoluteFill, { borderWidth: 0.8 * unit, borderColor: accent }] }))] }, index));
|
|
32
|
+
};
|
|
33
|
+
const boxStyle = ({ x, y, width: w, height: h, rotate }) => ({
|
|
34
|
+
position: 'absolute',
|
|
35
|
+
left: x * unit,
|
|
36
|
+
top: y * unit,
|
|
37
|
+
width: w * unit,
|
|
38
|
+
height: h * unit,
|
|
39
|
+
transform: rotate ? [{ rotate: `${rotate}deg` }] : undefined,
|
|
40
|
+
});
|
|
41
|
+
const pageCaption = caption ? _jsx(Text, { style: captionStyle, children: caption }) : null;
|
|
42
|
+
return (_jsxs(Paper, { width: width, side: side, children: [layout === 'single' && (_jsxs(_Fragment, { children: [slot(0, StyleSheet.absoluteFill), caption ? (_jsxs(View, { pointerEvents: "none", style: [styles.overlay, { paddingTop: 14 * unit, paddingHorizontal: 7 * unit, paddingBottom: 7 * unit }], children: [_jsx(Gradient, { to: "top", stops: [
|
|
43
|
+
{ offset: 0, color: '#000', opacity: 0.7 },
|
|
44
|
+
{ offset: 0.5, color: '#000', opacity: 0.3 },
|
|
45
|
+
{ offset: 1, color: '#000', opacity: 0 },
|
|
46
|
+
] }), _jsx(Text, { style: [serif.italic, { fontSize: 4.2 * unit, lineHeight: 5.7 * unit, color: '#fff' }], children: caption })] })) : null] })), layout === 'framed' && (_jsxs(View, { style: [styles.fill, { paddingTop: 10 * unit, paddingHorizontal: 9 * unit, paddingBottom: 6 * unit }], children: [slot(0, styles.flex, 'contain'), _jsx(Text, { style: [captionStyle, { marginTop: 5 * unit, minHeight: 6 * unit, fontSize: 4.2 * unit, lineHeight: 5.7 * unit }], children: caption })] })), layout === 'pair' && (_jsxs(View, { style: [styles.fill, { padding: 7 * unit, gap: 4 * unit }], children: [slot(0, styles.flex), slot(1, styles.flex), pageCaption] })), layout === 'grid' && (_jsxs(View, { style: [styles.fill, { padding: 7 * unit, gap: 3 * unit }], children: [[0, 2].map((first) => (_jsxs(View, { style: [styles.row, { gap: 3 * unit }], children: [slot(first, styles.flex), slot(first + 1, styles.flex)] }, first))), pageCaption] })), layout === 'collage' && (_jsxs(View, { style: [styles.fill, { padding: 7 * unit, gap: 3 * unit }], children: [slot(0, { flex: 1.4 }), _jsxs(View, { style: [styles.row, { gap: 3 * unit }], children: [slot(1, styles.flex), slot(2, styles.flex)] }), pageCaption] })), placed && (_jsxs(_Fragment, { children: [placed.slots.map((box, i) => slot(i, { ...boxStyle(box), ...(placed.loose && styles.loose) })), caption ? (_jsx(View, { pointerEvents: "none", style: [boxStyle(placed.caption), !placed.prose && styles.centered], children: _jsx(Text, { style: placed.prose ? [serif.upright, { fontSize: 4.4 * unit, lineHeight: 6.8 * unit, color: ink(look) }] : captionStyle, children: caption }) })) : null] })), layout === 'text' && (_jsxs(View, { style: [styles.fill, styles.centered, { paddingVertical: 14 * unit, paddingHorizontal: 12 * unit }], children: [_jsx(Text, { style: [serif.upright, { fontSize: 18 * unit, lineHeight: 16 * unit, height: 10 * unit, color: ink(look, 0.3) }], accessible: false, children: "\u201C" }), _jsx(Text, { style: [serif.italic, styles.caption, { marginTop: 4 * unit, fontSize: 6 * unit, lineHeight: 8.4 * unit, color: ink(look) }], children: caption || (onSlotPress ? 'Write something in the caption box below.' : '') })] })), number !== undefined && layout !== 'single' && (_jsx(Text, { style: [
|
|
47
|
+
serif.upright,
|
|
48
|
+
styles.number,
|
|
49
|
+
side === 'left' ? styles.left : styles.right,
|
|
50
|
+
{ paddingHorizontal: 5 * unit, paddingBottom: 2.6 * unit, fontSize: 2.8 * unit, color: ink(look, 0.6) },
|
|
51
|
+
], children: number }))] }));
|
|
52
|
+
}
|
|
53
|
+
/** A sheet of the book's paper: texture under the content, grain and page light over it. */
|
|
54
|
+
export function Paper({ width, side, children, style }) {
|
|
55
|
+
const look = usePaper();
|
|
56
|
+
return (_jsxs(View, { style: [styles.page, { width, height: width / PAGE_RATIO, backgroundColor: look.background }, style], children: [_jsx(Image, { source: look.texture, resizeMode: "repeat", style: [StyleSheet.absoluteFill, styles.tile, { opacity: look.textureOpacity }] }), _jsx(PageLight, { look: look }), children, _jsxs(View, { pointerEvents: "none", style: StyleSheet.absoluteFill, children: [_jsx(Image, { source: look.grain, resizeMode: "repeat", style: [StyleSheet.absoluteFill, styles.tile, { opacity: look.grainOpacity }] }), side && _jsx(Gutter, { side: side })] })] }));
|
|
57
|
+
}
|
|
58
|
+
/** Soft light from the top left and darker edges, like a real sheet. */
|
|
59
|
+
function PageLight({ look }) {
|
|
60
|
+
const id = useId().replace(/[^a-zA-Z0-9]/g, '');
|
|
61
|
+
return (_jsxs(Svg, { style: StyleSheet.absoluteFill, width: "100%", height: "100%", pointerEvents: "none", children: [_jsxs(Defs, { children: [_jsxs(RadialGradient, { id: `light${id}`, cx: "30%", cy: "20%", rx: "55%", ry: "55%", children: [_jsx(Stop, { offset: "0", stopColor: look.light }), _jsx(Stop, { offset: "1", stopColor: look.light, stopOpacity: 0 })] }), _jsxs(RadialGradient, { id: `vignette${id}`, cx: "50%", cy: "50%", rx: "70%", ry: "70%", children: [_jsx(Stop, { offset: "0.6", stopColor: look.vignette, stopOpacity: 0 }), _jsx(Stop, { offset: "1", stopColor: look.vignette })] })] }), _jsx(Rect, { width: "100%", height: "100%", fill: `url(#light${id})` }), _jsx(Rect, { width: "100%", height: "100%", fill: `url(#vignette${id})` })] }));
|
|
62
|
+
}
|
|
63
|
+
/** The page curving into the spine on a spread, and the sheen where it rises. */
|
|
64
|
+
function Gutter({ side }) {
|
|
65
|
+
const to = side === 'right' ? 'right' : 'left';
|
|
66
|
+
const shade = (a, offset) => ({ offset, color: 'rgb(20,12,4)', opacity: a });
|
|
67
|
+
const sheen = (a, offset) => ({ offset, color: 'rgb(255,250,240)', opacity: a });
|
|
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
|
+
}
|
|
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
|
+
/** Inside the front cover: a title page. */
|
|
94
|
+
export function TitlePageView({ book, width, side, }) {
|
|
95
|
+
const look = usePaper();
|
|
96
|
+
const serif = useSerif();
|
|
97
|
+
const unit = width / 100;
|
|
98
|
+
const credit = [book.author, book.year].filter(Boolean).join(' · ');
|
|
99
|
+
return (_jsx(Paper, { width: width, side: side, children: _jsxs(View, { style: [styles.fill, styles.centered, { padding: 12 * unit }], children: [_jsx(Ornament, { size: 9 * unit, color: ink(look) }), _jsx(Text, { style: [serif.upright, styles.center, { marginTop: 6 * unit, fontSize: 7.5 * unit, lineHeight: 8.3 * unit, color: ink(look) }], children: book.title }), book.subtitle ? (_jsx(Text, { style: [serif.italic, styles.center, { marginTop: 4 * unit, fontSize: 4 * unit, lineHeight: 5.8 * unit, color: ink(look) }], children: book.subtitle })) : null, _jsx(View, { style: { marginTop: 7 * unit, height: StyleSheet.hairlineWidth, width: 20 * unit, backgroundColor: ink(look, 0.3) } }), credit ? (_jsx(Text, { style: [styles.kicker, { marginTop: 5 * unit, fontSize: 2.6 * unit, letterSpacing: 0.52 * unit, color: ink(look) }], children: credit.toUpperCase() })) : null] }) }));
|
|
100
|
+
}
|
|
101
|
+
/** Inside the back cover, with the book's closing line. */
|
|
102
|
+
export function ColophonView({ book, width, side }) {
|
|
103
|
+
const look = usePaper();
|
|
104
|
+
const serif = useSerif();
|
|
105
|
+
const unit = width / 100;
|
|
106
|
+
return (_jsx(Paper, { width: width, side: side, children: _jsx(View, { style: [styles.fill, { alignItems: 'center', justifyContent: 'flex-end', padding: 12 * unit }], children: book.colophon ? (_jsx(Text, { style: [serif.italic, styles.center, { fontSize: 3.6 * unit, color: ink(look, 0.9) }], children: book.colophon })) : null }) }));
|
|
107
|
+
}
|
|
108
|
+
export function BlankPageView({ width, side }) {
|
|
109
|
+
return _jsx(Paper, { width: width, side: side });
|
|
110
|
+
}
|
|
111
|
+
function Ornament({ size, color }) {
|
|
112
|
+
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
|
+
}
|
|
114
|
+
const styles = StyleSheet.create({
|
|
115
|
+
page: { overflow: 'hidden' },
|
|
116
|
+
tile: { width: '100%', height: '100%' },
|
|
117
|
+
fill: { position: 'absolute', top: 0, right: 0, bottom: 0, left: 0 },
|
|
118
|
+
flex: { flex: 1 },
|
|
119
|
+
row: { flex: 1, flexDirection: 'row' },
|
|
120
|
+
centered: { alignItems: 'center', justifyContent: 'center' },
|
|
121
|
+
center: { textAlign: 'center' },
|
|
122
|
+
clip: { overflow: 'hidden' },
|
|
123
|
+
loose: { boxShadow: '0 2px 6px rgba(0,0,0,0.25)' },
|
|
124
|
+
empty: { alignItems: 'center', justifyContent: 'center', borderStyle: 'dashed' },
|
|
125
|
+
overlay: { position: 'absolute', left: 0, right: 0, bottom: 0 },
|
|
126
|
+
caption: { textAlign: 'center' },
|
|
127
|
+
number: { position: 'absolute', bottom: 0 },
|
|
128
|
+
left: { left: 0 },
|
|
129
|
+
right: { right: 0 },
|
|
130
|
+
hinge: { position: 'absolute', top: 0, bottom: 0 },
|
|
131
|
+
panel: { flex: 1, alignItems: 'center', borderWidth: StyleSheet.hairlineWidth },
|
|
132
|
+
kicker: { textAlign: 'center', opacity: 0.7 },
|
|
133
|
+
coverPhoto: { width: '100%', flex: 1, maxHeight: '62%', overflow: 'hidden' },
|
|
134
|
+
imprint: { position: 'absolute', left: 0, right: 0, alignItems: 'center' },
|
|
135
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@toneflix/flipbook-native",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/toneflix/flipbook",
|
|
9
|
+
"directory": "packages/native"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"flipbook",
|
|
13
|
+
"page turn",
|
|
14
|
+
"photo book",
|
|
15
|
+
"react-native",
|
|
16
|
+
"expo"
|
|
17
|
+
],
|
|
18
|
+
"main": "./lib/index.js",
|
|
19
|
+
"types": "./lib/index.d.ts",
|
|
20
|
+
"react-native": "./src/index.ts",
|
|
21
|
+
"source": "./src/index.ts",
|
|
22
|
+
"files": [
|
|
23
|
+
"lib",
|
|
24
|
+
"src",
|
|
25
|
+
"assets"
|
|
26
|
+
],
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@toneflix/flipbook-core": "^0.2.0"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"expo-screen-orientation": "*",
|
|
32
|
+
"react": ">=18",
|
|
33
|
+
"react-native": ">=0.74",
|
|
34
|
+
"react-native-svg": ">=15"
|
|
35
|
+
},
|
|
36
|
+
"peerDependenciesMeta": {
|
|
37
|
+
"expo-screen-orientation": {
|
|
38
|
+
"optional": true
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/react": "^19.3.0",
|
|
43
|
+
"react": "19.3.0",
|
|
44
|
+
"react-native": "0.88.1",
|
|
45
|
+
"react-native-svg": "^15.16.1",
|
|
46
|
+
"typescript": "~6.0.3"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"assets": "mkdir -p assets/textures && for f in ../core/textures/*.webp; do cp \"$f\" \"assets/textures/$(basename \"$f\" .webp)@2x.webp\"; done",
|
|
50
|
+
"build": "pnpm assets && rm -rf lib && tsc -p tsconfig.build.json",
|
|
51
|
+
"typecheck": "pnpm assets && tsc --noEmit"
|
|
52
|
+
}
|
|
6
53
|
}
|