@toneflix/flipbook-native 0.2.3 → 0.3.4
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/lib/Cover.js +14 -4
- package/lib/PrintedPhoto.d.ts +6 -2
- package/lib/PrintedPhoto.js +9 -5
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/pages.d.ts +6 -2
- package/lib/pages.js +2 -2
- package/lib/position.d.ts +21 -0
- package/lib/position.js +83 -0
- package/package.json +2 -2
- package/src/Cover.tsx +37 -5
- package/src/PrintedPhoto.tsx +22 -11
- package/src/index.ts +1 -0
- package/src/pages.tsx +26 -3
- package/src/position.tsx +120 -0
package/lib/Cover.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { useId } from 'react';
|
|
2
|
+
import { useId, useState } from 'react';
|
|
3
3
|
import { Image, StyleSheet, Text, View } from 'react-native';
|
|
4
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';
|
|
5
|
+
import { COVER_THEMES, DECO, FLEURON, PhotoCrop, coverHasText, } from '@toneflix/flipbook-core';
|
|
6
6
|
import { Gradient } from './Gradient';
|
|
7
7
|
import { PAPERS, useSerif } from './look';
|
|
8
8
|
import { Ornament, PAGE_RATIO } from './pages';
|
|
9
|
+
import { CroppedImage, useNaturalSize } from './position';
|
|
9
10
|
const CORNERS = ['tl', 'tr', 'bl', 'br'];
|
|
10
11
|
const MIRROR = {
|
|
11
12
|
tl: undefined,
|
|
@@ -31,12 +32,21 @@ function GiltFrame({ unit, color }) {
|
|
|
31
32
|
/** A photo clipped to an oval or an arch, ruled in the ink. */
|
|
32
33
|
function ShapedPhoto({ photo, shape, width, height, unit, color, show, }) {
|
|
33
34
|
const id = `c${useId().replace(/[^a-zA-Z0-9]/g, '')}`;
|
|
35
|
+
const natural = useNaturalSize(show ? photo : null);
|
|
36
|
+
const drawn = PhotoCrop.of(photo.position).rect({ width, height }, natural);
|
|
34
37
|
const r = width / 2;
|
|
35
38
|
const arch = `M0 ${height}V${r}A${r} ${r} 0 0 1 ${width} ${r}V${height}Z`;
|
|
36
39
|
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 }))] }));
|
|
40
|
+
return (_jsxs(Svg, { width: width, height: height, children: [_jsx(Defs, { children: _jsx(ClipPath, { id: id, children: outline }) }), show ? (_jsx(SvgImage, { href: { uri: photo.src }, x: drawn.left, y: drawn.top, width: drawn.width, height: drawn.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 }))] }));
|
|
41
|
+
}
|
|
42
|
+
/** A photo covering its parent, cropped by its position. */
|
|
43
|
+
function Photo({ photo, show }) {
|
|
44
|
+
const [box, setBox] = useState(null);
|
|
45
|
+
const natural = useNaturalSize(show ? photo : null);
|
|
46
|
+
if (!show)
|
|
47
|
+
return null;
|
|
48
|
+
return (_jsx(View, { style: StyleSheet.absoluteFill, onLayout: (e) => setBox(e.nativeEvent.layout), children: box && _jsx(CroppedImage, { photo: photo, crop: PhotoCrop.of(photo.position), box: box, image: natural }) }));
|
|
38
49
|
}
|
|
39
|
-
const Photo = ({ photo, show }) => show ? _jsx(Image, { source: { uri: photo.src }, style: StyleSheet.absoluteFill, resizeMode: "cover", "aria-label": photo.alt ?? undefined }) : null;
|
|
40
50
|
/** Cover board: cloth, light, the hinge shadow along the spine and a grain finish. */
|
|
41
51
|
function Board({ book, width, hinge, children }) {
|
|
42
52
|
const theme = COVER_THEMES[book.theme ?? 'classic'];
|
package/lib/PrintedPhoto.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type FlipbookPhoto, type FrameStyle } from '@toneflix/flipbook-core';
|
|
1
|
+
import { type FlipbookPhoto, type FrameStyle, type PhotoPosition } from '@toneflix/flipbook-core';
|
|
2
2
|
/**
|
|
3
3
|
* A photo printed on a page inside its frame. It fills its parent, or with `fit="contain"` keeps the
|
|
4
4
|
* photo's shape and centres it. `unit` is 1% of the page's width, so frames look the same at any size.
|
|
@@ -11,5 +11,9 @@ export interface PrintedPhotoProps {
|
|
|
11
11
|
fit?: 'cover' | 'contain';
|
|
12
12
|
/** False draws a placeholder instead of loading the image. */
|
|
13
13
|
show?: boolean;
|
|
14
|
+
/** Editor: lets people drag and pinch to choose which part of the photo shows. */
|
|
15
|
+
onPosition?: (position: PhotoPosition) => void;
|
|
16
|
+
/** Editor: called with true when a positioning gesture starts and false when it ends. */
|
|
17
|
+
onPositionGesture?: (active: boolean) => void;
|
|
14
18
|
}
|
|
15
|
-
export declare function PrintedPhoto({ photo, frame, unit, fit, show }: PrintedPhotoProps): import("react").JSX.Element;
|
|
19
|
+
export declare function PrintedPhoto({ photo, frame, unit, fit, show, onPosition, onPositionGesture }: PrintedPhotoProps): import("react").JSX.Element;
|
package/lib/PrintedPhoto.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useId, useState } from 'react';
|
|
3
|
-
import {
|
|
3
|
+
import { StyleSheet, View } from 'react-native';
|
|
4
4
|
import Svg, { ClipPath, Defs, Ellipse, G, Image as SvgImage, LinearGradient as SvgGradient, Path, Pattern, Polygon, Rect, Circle, Stop, } from 'react-native-svg';
|
|
5
5
|
import { BANDED_FRAMES } from '@toneflix/flipbook-core';
|
|
6
6
|
import { Gradient } from './Gradient';
|
|
7
7
|
import { ink, usePaper } from './look';
|
|
8
|
+
import { CroppedImage, useNaturalSize, usePhotoGestures } from './position';
|
|
8
9
|
const BANDS = BANDED_FRAMES;
|
|
9
10
|
/** The space inside the frame line (pad) and the frame line itself, in page units. Mirrors the web's .frame-* styles. */
|
|
10
11
|
function measure(frame, unit) {
|
|
@@ -32,7 +33,7 @@ function measure(frame, unit) {
|
|
|
32
33
|
return { pad: 0, line: 0 };
|
|
33
34
|
}
|
|
34
35
|
}
|
|
35
|
-
export function PrintedPhoto({ photo, frame, unit, fit = 'cover', show = true }) {
|
|
36
|
+
export function PrintedPhoto({ photo, frame, unit, fit = 'cover', show = true, onPosition, onPositionGesture }) {
|
|
36
37
|
const look = usePaper();
|
|
37
38
|
const [box, setBox] = useState(null);
|
|
38
39
|
const { pad, line } = measure(frame, unit);
|
|
@@ -45,7 +46,9 @@ export function PrintedPhoto({ photo, frame, unit, fit = 'cover', show = true })
|
|
|
45
46
|
height = (width - 2 * edge) / ratio + 2 * edge;
|
|
46
47
|
}
|
|
47
48
|
const inner = { left: edge, top: edge, width: Math.max(0, width - 2 * edge), height: Math.max(0, height - 2 * edge) };
|
|
48
|
-
|
|
49
|
+
const natural = useNaturalSize(show ? photo : null);
|
|
50
|
+
const { crop, handlers } = usePhotoGestures(photo?.position, inner, natural, photo && show ? onPosition : undefined, onPositionGesture);
|
|
51
|
+
return (_jsx(View, { style: styles.box, onLayout: (event) => setBox(event.nativeEvent.layout), children: box && (_jsxs(View, { style: { width, height }, ...handlers, children: [_jsx(Backing, { frame: frame, look: look, unit: unit, width: width, height: height, line: line }), frame === 'oval' ? (_jsx(OvalPhoto, { photo: show ? photo : null, crop: crop, natural: natural, look: look, rect: inner, width: width, height: height })) : (_jsxs(View, { style: [styles.abs, inner], children: [photo && show ? (_jsx(CroppedImage, { photo: photo, crop: crop, box: inner, image: natural })) : (_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
52
|
}
|
|
50
53
|
/** What is printed under the photo: rules, mats, tints and bands. */
|
|
51
54
|
function Backing({ frame, look, unit, width, height, line, }) {
|
|
@@ -142,9 +145,10 @@ function Band({ frame, look, unit, band, width, height, }) {
|
|
|
142
145
|
].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
146
|
}
|
|
144
147
|
/** The photo cut to an oval. */
|
|
145
|
-
function OvalPhoto({ photo, look, rect, width, height, }) {
|
|
148
|
+
function OvalPhoto({ photo, crop, natural, look, rect, width, height, }) {
|
|
146
149
|
const id = `oval${useId().replace(/[^a-zA-Z0-9]/g, '')}`;
|
|
147
|
-
|
|
150
|
+
const drawn = crop.rect(rect, natural);
|
|
151
|
+
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 + drawn.left, y: rect.top + drawn.top, width: drawn.width, height: drawn.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
152
|
}
|
|
149
153
|
/** Fades the photo's edges into the paper. */
|
|
150
154
|
function Feather({ look }) {
|
package/lib/index.d.ts
CHANGED
|
@@ -7,3 +7,4 @@ export { BookLookProvider, useBookLook, usePaper, useSerif, ink, PAPERS, type Bo
|
|
|
7
7
|
export { Gradient, type GradientStop } from './Gradient';
|
|
8
8
|
export { FoldLayout } from './geometry';
|
|
9
9
|
export { lockLandscape } from './orientation';
|
|
10
|
+
export { CroppedImage, useNaturalSize, usePhotoGestures, type CroppedImageProps } from './position';
|
package/lib/index.js
CHANGED
|
@@ -7,3 +7,4 @@ export { BookLookProvider, useBookLook, usePaper, useSerif, ink, PAPERS } from '
|
|
|
7
7
|
export { Gradient } from './Gradient';
|
|
8
8
|
export { FoldLayout } from './geometry';
|
|
9
9
|
export { lockLandscape } from './orientation';
|
|
10
|
+
export { CroppedImage, useNaturalSize, usePhotoGestures } from './position';
|
package/lib/pages.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
2
|
import { type ViewStyle } from 'react-native';
|
|
3
|
-
import { type FlipbookBook, type FlipbookPage } from '@toneflix/flipbook-core';
|
|
3
|
+
import { type FlipbookBook, type FlipbookPage, type PhotoPosition } from '@toneflix/flipbook-core';
|
|
4
4
|
/** Pages are 3:4, like the web's. */
|
|
5
5
|
export declare const PAGE_RATIO: number;
|
|
6
6
|
type Side = 'left' | 'right';
|
|
@@ -14,6 +14,10 @@ export interface PageViewProps {
|
|
|
14
14
|
/** Editor: makes each photo slot pressable. */
|
|
15
15
|
onSlotPress?: (slot: number) => void;
|
|
16
16
|
activeSlot?: number | null;
|
|
17
|
+
/** Editor: lets people drag and pinch the photo in the active slot to choose which part shows. */
|
|
18
|
+
onPhotoPosition?: (slot: number, position: PhotoPosition) => void;
|
|
19
|
+
/** Editor: true while a finger is moving a photo, e.g. to pause the scroll view the page sits in. */
|
|
20
|
+
onPositionGesture?: (active: boolean) => void;
|
|
17
21
|
/** Editor: colour of the highlighted slot. */
|
|
18
22
|
accent?: string;
|
|
19
23
|
}
|
|
@@ -21,7 +25,7 @@ export interface PageViewProps {
|
|
|
21
25
|
* The inside of one book page, `width` wide. Sizes are in units of 1% of the width, so the same
|
|
22
26
|
* page looks right as a thumbnail and full screen.
|
|
23
27
|
*/
|
|
24
|
-
export declare function PageView({ page, width, number, side, showImages, onSlotPress, activeSlot, accent }: PageViewProps): import("react").JSX.Element;
|
|
28
|
+
export declare function PageView({ page, width, number, side, showImages, onSlotPress, activeSlot, onPhotoPosition, onPositionGesture, accent, }: PageViewProps): import("react").JSX.Element;
|
|
25
29
|
/** A sheet of the book's paper: texture under the content, grain and page light over it. */
|
|
26
30
|
export declare function Paper({ width, side, children, style }: {
|
|
27
31
|
width: number;
|
package/lib/pages.js
CHANGED
|
@@ -12,7 +12,7 @@ export const PAGE_RATIO = 3 / 4;
|
|
|
12
12
|
* The inside of one book page, `width` wide. Sizes are in units of 1% of the width, so the same
|
|
13
13
|
* page looks right as a thumbnail and full screen.
|
|
14
14
|
*/
|
|
15
|
-
export function PageView({ page, width, number, side, showImages = true, onSlotPress, activeSlot, accent = '#00afff' }) {
|
|
15
|
+
export function PageView({ page, width, number, side, showImages = true, onSlotPress, activeSlot, onPhotoPosition, onPositionGesture, accent = '#00afff', }) {
|
|
16
16
|
const look = usePaper();
|
|
17
17
|
const serif = useSerif();
|
|
18
18
|
const unit = width / 100;
|
|
@@ -23,7 +23,7 @@ export function PageView({ page, width, number, side, showImages = true, onSlotP
|
|
|
23
23
|
const slot = (index, style, fit = 'cover') => {
|
|
24
24
|
const photo = photos[index] ?? null;
|
|
25
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: [
|
|
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, onPosition: onPhotoPosition && activeSlot === index ? (position) => onPhotoPosition(index, position) : undefined, onPositionGesture: onPositionGesture })) : (_jsx(View, { style: [
|
|
27
27
|
StyleSheet.absoluteFill,
|
|
28
28
|
styles.empty,
|
|
29
29
|
{ backgroundColor: ink(look, 0.1) },
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { PhotoCrop, type FlipbookPhoto, type PhotoPosition, type Size } from '@toneflix/flipbook-core';
|
|
2
|
+
/** The photo's own size: from the photo data, else read from the image once. */
|
|
3
|
+
export declare function useNaturalSize(photo: Pick<FlipbookPhoto, 'src' | 'width' | 'height'> | null): Size | null;
|
|
4
|
+
/**
|
|
5
|
+
* Drag to move a photo in its box and pinch to zoom it. Returns the position to draw, which follows the
|
|
6
|
+
* fingers, and the handlers for the box. `onChange` gets the new position when the fingers lift; without it
|
|
7
|
+
* the photo stays put. `onGesture` hears when fingers go down and lift, e.g. to pause a scroll view.
|
|
8
|
+
*/
|
|
9
|
+
export declare function usePhotoGestures(position: Partial<PhotoPosition> | null | undefined, box: Size, image: Size | null, onChange?: (position: PhotoPosition) => void, onGesture?: (active: boolean) => void): {
|
|
10
|
+
crop: PhotoCrop;
|
|
11
|
+
handlers: {};
|
|
12
|
+
};
|
|
13
|
+
export interface CroppedImageProps {
|
|
14
|
+
photo: FlipbookPhoto;
|
|
15
|
+
crop: PhotoCrop;
|
|
16
|
+
/** The box the photo covers, which this fills. */
|
|
17
|
+
box: Size;
|
|
18
|
+
image: Size | null;
|
|
19
|
+
}
|
|
20
|
+
/** A photo covering its box, moved and zoomed by `crop`. */
|
|
21
|
+
export declare function CroppedImage({ photo, crop, box, image }: CroppedImageProps): import("react").JSX.Element;
|
package/lib/position.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import { Image, PanResponder, StyleSheet, View } from 'react-native';
|
|
4
|
+
import { PhotoCrop } from '@toneflix/flipbook-core';
|
|
5
|
+
/** The photo's own size: from the photo data, else read from the image once. */
|
|
6
|
+
export function useNaturalSize(photo) {
|
|
7
|
+
const known = photo?.width && photo.height ? { width: photo.width, height: photo.height } : null;
|
|
8
|
+
const [read, setRead] = useState(null);
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
if (known || !photo)
|
|
11
|
+
return;
|
|
12
|
+
let live = true;
|
|
13
|
+
Image.getSize(photo.src, (width, height) => live && setRead({ src: photo.src, size: { width, height } }), () => undefined);
|
|
14
|
+
return () => {
|
|
15
|
+
live = false;
|
|
16
|
+
};
|
|
17
|
+
}, [photo?.src, !!known]);
|
|
18
|
+
return known ?? (read && read.src === photo?.src ? read.size : null);
|
|
19
|
+
}
|
|
20
|
+
const spread = (e) => {
|
|
21
|
+
const [a, b] = e.nativeEvent.touches;
|
|
22
|
+
return a && b ? Math.hypot(a.pageX - b.pageX, a.pageY - b.pageY) : 0;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Drag to move a photo in its box and pinch to zoom it. Returns the position to draw, which follows the
|
|
26
|
+
* fingers, and the handlers for the box. `onChange` gets the new position when the fingers lift; without it
|
|
27
|
+
* the photo stays put. `onGesture` hears when fingers go down and lift, e.g. to pause a scroll view.
|
|
28
|
+
*/
|
|
29
|
+
export function usePhotoGestures(position, box, image, onChange, onGesture) {
|
|
30
|
+
const [moving, setMoving] = useState(null);
|
|
31
|
+
const latest = useRef({ position, box, image, onChange, onGesture });
|
|
32
|
+
latest.current = { position, box, image, onChange, onGesture };
|
|
33
|
+
useEffect(() => setMoving(null), [position?.x, position?.y, position?.zoom]);
|
|
34
|
+
const responder = useMemo(() => {
|
|
35
|
+
let crop = PhotoCrop.CENTRE;
|
|
36
|
+
let last = { dx: 0, dy: 0 };
|
|
37
|
+
let pinch = { spread: 0, zoom: 1 };
|
|
38
|
+
const finish = () => {
|
|
39
|
+
const { position, onChange, onGesture } = latest.current;
|
|
40
|
+
onGesture?.(false);
|
|
41
|
+
if (crop.equals(position))
|
|
42
|
+
setMoving(null);
|
|
43
|
+
else
|
|
44
|
+
onChange?.(crop.toJSON());
|
|
45
|
+
};
|
|
46
|
+
return PanResponder.create({
|
|
47
|
+
onStartShouldSetPanResponder: () => !!latest.current.onChange,
|
|
48
|
+
onMoveShouldSetPanResponder: () => !!latest.current.onChange,
|
|
49
|
+
onPanResponderTerminationRequest: () => false,
|
|
50
|
+
onPanResponderGrant: (e) => {
|
|
51
|
+
latest.current.onGesture?.(true);
|
|
52
|
+
crop = PhotoCrop.of(latest.current.position);
|
|
53
|
+
last = { dx: 0, dy: 0 };
|
|
54
|
+
pinch = { spread: spread(e), zoom: crop.zoom };
|
|
55
|
+
},
|
|
56
|
+
onPanResponderMove: (e, { dx, dy }) => {
|
|
57
|
+
const now = spread(e);
|
|
58
|
+
if (now && pinch.spread)
|
|
59
|
+
crop = crop.zoomTo((pinch.zoom * now) / pinch.spread);
|
|
60
|
+
else if (now)
|
|
61
|
+
pinch = { spread: now, zoom: crop.zoom };
|
|
62
|
+
else {
|
|
63
|
+
pinch.spread = 0;
|
|
64
|
+
crop = crop.pan(dx - last.dx, dy - last.dy, latest.current.box, latest.current.image);
|
|
65
|
+
}
|
|
66
|
+
last = { dx, dy };
|
|
67
|
+
setMoving(crop);
|
|
68
|
+
},
|
|
69
|
+
onPanResponderRelease: finish,
|
|
70
|
+
onPanResponderTerminate: finish,
|
|
71
|
+
});
|
|
72
|
+
}, []);
|
|
73
|
+
return { crop: moving ?? PhotoCrop.of(position), handlers: onChange ? responder.panHandlers : {} };
|
|
74
|
+
}
|
|
75
|
+
/** A photo covering its box, moved and zoomed by `crop`. */
|
|
76
|
+
export function CroppedImage({ photo, crop, box, image }) {
|
|
77
|
+
const rect = crop.rect(box, image);
|
|
78
|
+
return (_jsx(View, { style: [StyleSheet.absoluteFill, styles.clip], children: _jsx(Image, { source: { uri: photo.src }, style: [styles.abs, rect], resizeMode: "cover", "aria-label": photo.alt ?? undefined }) }));
|
|
79
|
+
}
|
|
80
|
+
const styles = StyleSheet.create({
|
|
81
|
+
abs: { position: 'absolute' },
|
|
82
|
+
clip: { overflow: 'hidden' },
|
|
83
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@toneflix/flipbook-native",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.4",
|
|
4
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
6
|
"homepage": "https://toneflix.github.io/flipbook/frameworks/react-native",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"assets"
|
|
30
30
|
],
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@toneflix/flipbook-core": "^0.
|
|
32
|
+
"@toneflix/flipbook-core": "^0.3.4"
|
|
33
33
|
},
|
|
34
34
|
"peerDependencies": {
|
|
35
35
|
"expo-screen-orientation": "*",
|
package/src/Cover.tsx
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
|
-
import { useId, type ReactNode } from 'react'
|
|
1
|
+
import { useId, useState, type ReactNode } from 'react'
|
|
2
2
|
import { Image, StyleSheet, Text, View, type TextStyle, type ViewStyle } from 'react-native'
|
|
3
3
|
import Svg, { ClipPath, Defs, Ellipse, Image as SvgImage, Path, RadialGradient, Rect, Stop } from 'react-native-svg'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
COVER_THEMES,
|
|
6
|
+
DECO,
|
|
7
|
+
FLEURON,
|
|
8
|
+
PhotoCrop,
|
|
9
|
+
coverHasText,
|
|
10
|
+
type CoverArt,
|
|
11
|
+
type FlipbookBook,
|
|
12
|
+
type FlipbookPhoto,
|
|
13
|
+
type Size,
|
|
14
|
+
} from '@toneflix/flipbook-core'
|
|
5
15
|
import { Gradient } from './Gradient'
|
|
6
16
|
import { PAPERS, useSerif } from './look'
|
|
7
17
|
import { Ornament, PAGE_RATIO } from './pages'
|
|
18
|
+
import { CroppedImage, useNaturalSize } from './position'
|
|
8
19
|
|
|
9
20
|
type Side = 'left' | 'right'
|
|
10
21
|
type Corner = 'tl' | 'tr' | 'bl' | 'br'
|
|
@@ -79,6 +90,8 @@ function ShapedPhoto({
|
|
|
79
90
|
show: boolean
|
|
80
91
|
}) {
|
|
81
92
|
const id = `c${useId().replace(/[^a-zA-Z0-9]/g, '')}`
|
|
93
|
+
const natural = useNaturalSize(show ? photo : null)
|
|
94
|
+
const drawn = PhotoCrop.of(photo.position).rect({ width, height }, natural)
|
|
82
95
|
const r = width / 2
|
|
83
96
|
const arch = `M0 ${height}V${r}A${r} ${r} 0 0 1 ${width} ${r}V${height}Z`
|
|
84
97
|
const outline = shape === 'oval' ? <Ellipse cx={r} cy={height / 2} rx={r - 0.3 * unit} ry={height / 2 - 0.3 * unit} /> : <Path d={arch} />
|
|
@@ -89,7 +102,15 @@ function ShapedPhoto({
|
|
|
89
102
|
<ClipPath id={id}>{outline}</ClipPath>
|
|
90
103
|
</Defs>
|
|
91
104
|
{show ? (
|
|
92
|
-
<SvgImage
|
|
105
|
+
<SvgImage
|
|
106
|
+
href={{ uri: photo.src }}
|
|
107
|
+
x={drawn.left}
|
|
108
|
+
y={drawn.top}
|
|
109
|
+
width={drawn.width}
|
|
110
|
+
height={drawn.height}
|
|
111
|
+
preserveAspectRatio="xMidYMid slice"
|
|
112
|
+
clipPath={`url(#${id})`}
|
|
113
|
+
/>
|
|
93
114
|
) : (
|
|
94
115
|
<Rect width={width} height={height} fill={color} fillOpacity={0.15} clipPath={`url(#${id})`} />
|
|
95
116
|
)}
|
|
@@ -110,8 +131,19 @@ function ShapedPhoto({
|
|
|
110
131
|
)
|
|
111
132
|
}
|
|
112
133
|
|
|
113
|
-
|
|
114
|
-
|
|
134
|
+
/** A photo covering its parent, cropped by its position. */
|
|
135
|
+
function Photo({ photo, show }: { photo: FlipbookPhoto; show: boolean }) {
|
|
136
|
+
const [box, setBox] = useState<Size | null>(null)
|
|
137
|
+
const natural = useNaturalSize(show ? photo : null)
|
|
138
|
+
|
|
139
|
+
if (!show) return null
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<View style={StyleSheet.absoluteFill} onLayout={(e) => setBox(e.nativeEvent.layout)}>
|
|
143
|
+
{box && <CroppedImage photo={photo} crop={PhotoCrop.of(photo.position)} box={box} image={natural} />}
|
|
144
|
+
</View>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
115
147
|
|
|
116
148
|
/** Cover board: cloth, light, the hinge shadow along the spine and a grain finish. */
|
|
117
149
|
function Board({ book, width, hinge, children }: { book: Pick<FlipbookBook, 'theme'>; width: number; hinge: Side; children?: ReactNode }) {
|
package/src/PrintedPhoto.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useId, useState } from 'react'
|
|
2
|
-
import {
|
|
2
|
+
import { StyleSheet, View, type LayoutChangeEvent, type ViewStyle } from 'react-native'
|
|
3
3
|
import Svg, {
|
|
4
4
|
ClipPath,
|
|
5
5
|
Defs,
|
|
@@ -14,9 +14,10 @@ import Svg, {
|
|
|
14
14
|
Circle,
|
|
15
15
|
Stop,
|
|
16
16
|
} from 'react-native-svg'
|
|
17
|
-
import { BANDED_FRAMES, type FlipbookPhoto, type FrameStyle } from '@toneflix/flipbook-core'
|
|
17
|
+
import { BANDED_FRAMES, type FlipbookPhoto, type FrameStyle, type PhotoCrop, type PhotoPosition, type Size } from '@toneflix/flipbook-core'
|
|
18
18
|
import { Gradient } from './Gradient'
|
|
19
19
|
import { ink, usePaper, type PaperLook } from './look'
|
|
20
|
+
import { CroppedImage, useNaturalSize, usePhotoGestures } from './position'
|
|
20
21
|
|
|
21
22
|
type Box = { left: number; top: number; width: number; height: number }
|
|
22
23
|
|
|
@@ -62,9 +63,13 @@ export interface PrintedPhotoProps {
|
|
|
62
63
|
fit?: 'cover' | 'contain'
|
|
63
64
|
/** False draws a placeholder instead of loading the image. */
|
|
64
65
|
show?: boolean
|
|
66
|
+
/** Editor: lets people drag and pinch to choose which part of the photo shows. */
|
|
67
|
+
onPosition?: (position: PhotoPosition) => void
|
|
68
|
+
/** Editor: called with true when a positioning gesture starts and false when it ends. */
|
|
69
|
+
onPositionGesture?: (active: boolean) => void
|
|
65
70
|
}
|
|
66
71
|
|
|
67
|
-
export function PrintedPhoto({ photo, frame, unit, fit = 'cover', show = true }: PrintedPhotoProps) {
|
|
72
|
+
export function PrintedPhoto({ photo, frame, unit, fit = 'cover', show = true, onPosition, onPositionGesture }: PrintedPhotoProps) {
|
|
68
73
|
const look = usePaper()
|
|
69
74
|
const [box, setBox] = useState<{ width: number; height: number } | null>(null)
|
|
70
75
|
const { pad, line } = measure(frame, unit)
|
|
@@ -80,18 +85,20 @@ export function PrintedPhoto({ photo, frame, unit, fit = 'cover', show = true }:
|
|
|
80
85
|
}
|
|
81
86
|
|
|
82
87
|
const inner: Box = { left: edge, top: edge, width: Math.max(0, width - 2 * edge), height: Math.max(0, height - 2 * edge) }
|
|
88
|
+
const natural = useNaturalSize(show ? photo : null)
|
|
89
|
+
const { crop, handlers } = usePhotoGestures(photo?.position, inner, natural, photo && show ? onPosition : undefined, onPositionGesture)
|
|
83
90
|
|
|
84
91
|
return (
|
|
85
92
|
<View style={styles.box} onLayout={(event: LayoutChangeEvent) => setBox(event.nativeEvent.layout)}>
|
|
86
93
|
{box && (
|
|
87
|
-
<View style={{ width, height }}>
|
|
94
|
+
<View style={{ width, height }} {...handlers}>
|
|
88
95
|
<Backing frame={frame} look={look} unit={unit} width={width} height={height} line={line} />
|
|
89
96
|
{frame === 'oval' ? (
|
|
90
|
-
<OvalPhoto photo={show ? photo : null} look={look} rect={inner} width={width} height={height}
|
|
97
|
+
<OvalPhoto photo={show ? photo : null} crop={crop} natural={natural} look={look} rect={inner} width={width} height={height} />
|
|
91
98
|
) : (
|
|
92
99
|
<View style={[styles.abs, inner]}>
|
|
93
100
|
{photo && show ? (
|
|
94
|
-
<
|
|
101
|
+
<CroppedImage photo={photo} crop={crop} box={inner} image={natural} />
|
|
95
102
|
) : (
|
|
96
103
|
<View style={[StyleSheet.absoluteFill, { backgroundColor: ink(look, 0.15) }]} />
|
|
97
104
|
)}
|
|
@@ -369,19 +376,23 @@ function Band({
|
|
|
369
376
|
/** The photo cut to an oval. */
|
|
370
377
|
function OvalPhoto({
|
|
371
378
|
photo,
|
|
379
|
+
crop,
|
|
380
|
+
natural,
|
|
372
381
|
look,
|
|
373
382
|
rect,
|
|
374
383
|
width,
|
|
375
384
|
height,
|
|
376
385
|
}: {
|
|
377
386
|
photo: FlipbookPhoto | null
|
|
387
|
+
crop: PhotoCrop
|
|
388
|
+
natural: Size | null
|
|
378
389
|
look: PaperLook
|
|
379
390
|
rect: Box
|
|
380
391
|
width: number
|
|
381
392
|
height: number
|
|
382
|
-
line: number
|
|
383
393
|
}) {
|
|
384
394
|
const id = `oval${useId().replace(/[^a-zA-Z0-9]/g, '')}`
|
|
395
|
+
const drawn = crop.rect(rect, natural)
|
|
385
396
|
|
|
386
397
|
return (
|
|
387
398
|
<Svg width={width} height={height} style={styles.abs}>
|
|
@@ -393,10 +404,10 @@ function OvalPhoto({
|
|
|
393
404
|
{photo ? (
|
|
394
405
|
<SvgImage
|
|
395
406
|
href={{ uri: photo.src }}
|
|
396
|
-
x={rect.left}
|
|
397
|
-
y={rect.top}
|
|
398
|
-
width={
|
|
399
|
-
height={
|
|
407
|
+
x={rect.left + drawn.left}
|
|
408
|
+
y={rect.top + drawn.top}
|
|
409
|
+
width={drawn.width}
|
|
410
|
+
height={drawn.height}
|
|
400
411
|
preserveAspectRatio="xMidYMid slice"
|
|
401
412
|
clipPath={`url(#${id})`}
|
|
402
413
|
/>
|
package/src/index.ts
CHANGED
|
@@ -7,3 +7,4 @@ export { BookLookProvider, useBookLook, usePaper, useSerif, ink, PAPERS, type Bo
|
|
|
7
7
|
export { Gradient, type GradientStop } from './Gradient'
|
|
8
8
|
export { FoldLayout } from './geometry'
|
|
9
9
|
export { lockLandscape } from './orientation'
|
|
10
|
+
export { CroppedImage, useNaturalSize, usePhotoGestures, type CroppedImageProps } from './position'
|
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 { PLACED_LAYOUTS, photoFrame, type FlipbookBook, type FlipbookPage, type PageBox } from '@toneflix/flipbook-core'
|
|
4
|
+
import { PLACED_LAYOUTS, photoFrame, type FlipbookBook, type FlipbookPage, type PageBox, type PhotoPosition } 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'
|
|
@@ -21,6 +21,10 @@ export interface PageViewProps {
|
|
|
21
21
|
/** Editor: makes each photo slot pressable. */
|
|
22
22
|
onSlotPress?: (slot: number) => void
|
|
23
23
|
activeSlot?: number | null
|
|
24
|
+
/** Editor: lets people drag and pinch the photo in the active slot to choose which part shows. */
|
|
25
|
+
onPhotoPosition?: (slot: number, position: PhotoPosition) => void
|
|
26
|
+
/** Editor: true while a finger is moving a photo, e.g. to pause the scroll view the page sits in. */
|
|
27
|
+
onPositionGesture?: (active: boolean) => void
|
|
24
28
|
/** Editor: colour of the highlighted slot. */
|
|
25
29
|
accent?: string
|
|
26
30
|
}
|
|
@@ -29,7 +33,18 @@ export interface PageViewProps {
|
|
|
29
33
|
* The inside of one book page, `width` wide. Sizes are in units of 1% of the width, so the same
|
|
30
34
|
* page looks right as a thumbnail and full screen.
|
|
31
35
|
*/
|
|
32
|
-
export function PageView({
|
|
36
|
+
export function PageView({
|
|
37
|
+
page,
|
|
38
|
+
width,
|
|
39
|
+
number,
|
|
40
|
+
side,
|
|
41
|
+
showImages = true,
|
|
42
|
+
onSlotPress,
|
|
43
|
+
activeSlot,
|
|
44
|
+
onPhotoPosition,
|
|
45
|
+
onPositionGesture,
|
|
46
|
+
accent = '#00afff',
|
|
47
|
+
}: PageViewProps) {
|
|
33
48
|
const look = usePaper()
|
|
34
49
|
const serif = useSerif()
|
|
35
50
|
const unit = width / 100
|
|
@@ -52,7 +67,15 @@ export function PageView({ page, width, number, side, showImages = true, onSlotP
|
|
|
52
67
|
style={[style, fit === 'cover' && styles.clip]}
|
|
53
68
|
>
|
|
54
69
|
{photo ? (
|
|
55
|
-
<PrintedPhoto
|
|
70
|
+
<PrintedPhoto
|
|
71
|
+
photo={photo}
|
|
72
|
+
frame={frame}
|
|
73
|
+
unit={unit}
|
|
74
|
+
fit={fit}
|
|
75
|
+
show={showImages}
|
|
76
|
+
onPosition={onPhotoPosition && activeSlot === index ? (position) => onPhotoPosition(index, position) : undefined}
|
|
77
|
+
onPositionGesture={onPositionGesture}
|
|
78
|
+
/>
|
|
56
79
|
) : (
|
|
57
80
|
<View
|
|
58
81
|
style={[
|
package/src/position.tsx
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { Image, PanResponder, StyleSheet, View, type GestureResponderEvent } from 'react-native'
|
|
3
|
+
import { PhotoCrop, type FlipbookPhoto, type PhotoPosition, type Size } from '@toneflix/flipbook-core'
|
|
4
|
+
|
|
5
|
+
/** The photo's own size: from the photo data, else read from the image once. */
|
|
6
|
+
export function useNaturalSize(photo: Pick<FlipbookPhoto, 'src' | 'width' | 'height'> | null): Size | null {
|
|
7
|
+
const known = photo?.width && photo.height ? { width: photo.width, height: photo.height } : null
|
|
8
|
+
const [read, setRead] = useState<{ src: string; size: Size } | null>(null)
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
if (known || !photo) return
|
|
12
|
+
|
|
13
|
+
let live = true
|
|
14
|
+
|
|
15
|
+
Image.getSize(
|
|
16
|
+
photo.src,
|
|
17
|
+
(width, height) => live && setRead({ src: photo.src, size: { width, height } }),
|
|
18
|
+
() => undefined,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
return () => {
|
|
22
|
+
live = false
|
|
23
|
+
}
|
|
24
|
+
}, [photo?.src, !!known])
|
|
25
|
+
|
|
26
|
+
return known ?? (read && read.src === photo?.src ? read.size : null)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const spread = (e: GestureResponderEvent) => {
|
|
30
|
+
const [a, b] = e.nativeEvent.touches
|
|
31
|
+
|
|
32
|
+
return a && b ? Math.hypot(a.pageX - b.pageX, a.pageY - b.pageY) : 0
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Drag to move a photo in its box and pinch to zoom it. Returns the position to draw, which follows the
|
|
37
|
+
* fingers, and the handlers for the box. `onChange` gets the new position when the fingers lift; without it
|
|
38
|
+
* the photo stays put. `onGesture` hears when fingers go down and lift, e.g. to pause a scroll view.
|
|
39
|
+
*/
|
|
40
|
+
export function usePhotoGestures(
|
|
41
|
+
position: Partial<PhotoPosition> | null | undefined,
|
|
42
|
+
box: Size,
|
|
43
|
+
image: Size | null,
|
|
44
|
+
onChange?: (position: PhotoPosition) => void,
|
|
45
|
+
onGesture?: (active: boolean) => void,
|
|
46
|
+
) {
|
|
47
|
+
const [moving, setMoving] = useState<PhotoCrop | null>(null)
|
|
48
|
+
const latest = useRef({ position, box, image, onChange, onGesture })
|
|
49
|
+
|
|
50
|
+
latest.current = { position, box, image, onChange, onGesture }
|
|
51
|
+
|
|
52
|
+
useEffect(() => setMoving(null), [position?.x, position?.y, position?.zoom])
|
|
53
|
+
|
|
54
|
+
const responder = useMemo(() => {
|
|
55
|
+
let crop = PhotoCrop.CENTRE
|
|
56
|
+
let last = { dx: 0, dy: 0 }
|
|
57
|
+
let pinch = { spread: 0, zoom: 1 }
|
|
58
|
+
|
|
59
|
+
const finish = () => {
|
|
60
|
+
const { position, onChange, onGesture } = latest.current
|
|
61
|
+
|
|
62
|
+
onGesture?.(false)
|
|
63
|
+
if (crop.equals(position)) setMoving(null)
|
|
64
|
+
else onChange?.(crop.toJSON())
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return PanResponder.create({
|
|
68
|
+
onStartShouldSetPanResponder: () => !!latest.current.onChange,
|
|
69
|
+
onMoveShouldSetPanResponder: () => !!latest.current.onChange,
|
|
70
|
+
onPanResponderTerminationRequest: () => false,
|
|
71
|
+
onPanResponderGrant: (e) => {
|
|
72
|
+
latest.current.onGesture?.(true)
|
|
73
|
+
crop = PhotoCrop.of(latest.current.position)
|
|
74
|
+
last = { dx: 0, dy: 0 }
|
|
75
|
+
pinch = { spread: spread(e), zoom: crop.zoom }
|
|
76
|
+
},
|
|
77
|
+
onPanResponderMove: (e, { dx, dy }) => {
|
|
78
|
+
const now = spread(e)
|
|
79
|
+
|
|
80
|
+
if (now && pinch.spread) crop = crop.zoomTo((pinch.zoom * now) / pinch.spread)
|
|
81
|
+
else if (now) pinch = { spread: now, zoom: crop.zoom }
|
|
82
|
+
else {
|
|
83
|
+
pinch.spread = 0
|
|
84
|
+
crop = crop.pan(dx - last.dx, dy - last.dy, latest.current.box, latest.current.image)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
last = { dx, dy }
|
|
88
|
+
setMoving(crop)
|
|
89
|
+
},
|
|
90
|
+
onPanResponderRelease: finish,
|
|
91
|
+
onPanResponderTerminate: finish,
|
|
92
|
+
})
|
|
93
|
+
}, [])
|
|
94
|
+
|
|
95
|
+
return { crop: moving ?? PhotoCrop.of(position), handlers: onChange ? responder.panHandlers : {} }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface CroppedImageProps {
|
|
99
|
+
photo: FlipbookPhoto
|
|
100
|
+
crop: PhotoCrop
|
|
101
|
+
/** The box the photo covers, which this fills. */
|
|
102
|
+
box: Size
|
|
103
|
+
image: Size | null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A photo covering its box, moved and zoomed by `crop`. */
|
|
107
|
+
export function CroppedImage({ photo, crop, box, image }: CroppedImageProps) {
|
|
108
|
+
const rect = crop.rect(box, image)
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<View style={[StyleSheet.absoluteFill, styles.clip]}>
|
|
112
|
+
<Image source={{ uri: photo.src }} style={[styles.abs, rect]} resizeMode="cover" aria-label={photo.alt ?? undefined} />
|
|
113
|
+
</View>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const styles = StyleSheet.create({
|
|
118
|
+
abs: { position: 'absolute' },
|
|
119
|
+
clip: { overflow: 'hidden' },
|
|
120
|
+
})
|