@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 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'];
@@ -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;
@@ -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 { Image, StyleSheet, View } from 'react-native';
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
- 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
+ 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
- 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) }))] }));
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;
@@ -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.2.3",
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.2.3"
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 { COVER_THEMES, DECO, FLEURON, coverHasText, type CoverArt, type FlipbookBook, type FlipbookPhoto } from '@toneflix/flipbook-core'
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 href={{ uri: photo.src }} width={width} height={height} preserveAspectRatio="xMidYMid slice" clipPath={`url(#${id})`} />
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
- const Photo = ({ photo, show }: { photo: FlipbookPhoto; show: boolean }) =>
114
- show ? <Image source={{ uri: photo.src }} style={StyleSheet.absoluteFill} resizeMode="cover" aria-label={photo.alt ?? undefined} /> : null
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 }) {
@@ -1,5 +1,5 @@
1
1
  import { useId, useState } from 'react'
2
- import { Image, StyleSheet, View, type LayoutChangeEvent, type ViewStyle } from 'react-native'
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} line={line} />
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
- <Image source={{ uri: photo.src }} style={StyleSheet.absoluteFill} resizeMode="cover" aria-label={photo.alt ?? undefined} />
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={rect.width}
399
- height={rect.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({ page, width, number, side, showImages = true, onSlotPress, activeSlot, accent = '#00afff' }: PageViewProps) {
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 photo={photo} frame={frame} unit={unit} fit={fit} show={showImages} />
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={[
@@ -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
+ })