From a4ed78ae644aab8a92fa1b91c23e5524228f2fc8 Mon Sep 17 00:00:00 2001 From: adithyavis Date: Tue, 14 Jul 2026 00:07:28 +0530 Subject: [PATCH 01/16] feat: canva example --- example/app/canva.tsx | 199 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 example/app/canva.tsx diff --git a/example/app/canva.tsx b/example/app/canva.tsx new file mode 100644 index 0000000..6b64ca0 --- /dev/null +++ b/example/app/canva.tsx @@ -0,0 +1,199 @@ +import { useMemo, useState, type Dispatch, type SetStateAction } from 'react'; +import { StyleSheet, View } from 'react-native'; +import { Asset } from 'expo-asset'; +import { + Stage, + Layer, + Image, + Text, + Transformer, + useFont, + type EventObject, + type TransformEvent, + type TransformResult, +} from 'react-native-canvas-kit'; +import { CanvaChrome } from '../src/CanvaChrome'; +import { useTouchTracker, TouchRings } from '../src/TouchOverlay'; +import { CROP_PHOTOS } from '../src/cropPhotos'; +import { FONT_URL, sel, type NodeTransform } from '../src/constants'; + +const CANVA_PURPLE = '#8B3DFF'; +const TITLE_ID = 'title'; +const TITLE_TEXT = 'Canva'; + +const QUADRANTS = [ + { id: 'photo-top-left', src: CROP_PHOTOS[0]!.src, column: 0, row: 0 }, + { id: 'photo-top-right', src: CROP_PHOTOS[1]!.src, column: 1, row: 0 }, + { id: 'photo-bottom-left', src: CROP_PHOTOS[2]!.src, column: 0, row: 1 }, + { id: 'photo-bottom-right', src: CROP_PHOTOS[3]!.src, column: 1, row: 1 }, +]; + +export default function CanvaScreen() { + const [selected, setSelected] = useState(null); + + return ( + setSelected(null)} + > + {({ width, height }) => ( + + )} + + ); +} + +interface CanvaBoardProps { + width: number; + height: number; + selected: string | null; + setSelected: Dispatch>; +} + +function CanvaBoard({ width, height, selected, setSelected }: CanvaBoardProps) { + const quadrantWidth = width / 2; + const quadrantHeight = height / 2; + const font = useFont(FONT_URL, Math.round(width * 0.2)); + const { gesture: touchGesture, touches } = useTouchTracker(); + + const [transforms, setTransforms] = useState>( + () => { + const initial: Record = {}; + for (const quadrant of QUADRANTS) { + initial[sel(quadrant.id)] = { + x: quadrant.column * quadrantWidth, + y: quadrant.row * quadrantHeight, + scaleX: 1, + scaleY: 1, + rotation: 0, + }; + } + initial[sel(TITLE_ID)] = { + x: width / 2, + y: height / 2, + scaleX: 1, + scaleY: 1, + rotation: 0, + }; + return initial; + } + ); + + const commit = (selector: string, result: TransformResult) => + setTransforms((prev) => ({ + ...prev, + [selector]: { + x: result.x, + y: result.y, + scaleX: result.scaleX, + scaleY: result.scaleY, + rotation: result.rotation, + }, + })); + + const titleOffset = useMemo(() => { + if (!font) return null; + const glyphWidths = font.getGlyphWidths(font.getGlyphIDs(TITLE_TEXT)); + const textWidth = glyphWidths.reduce((sum, value) => sum + value, 0); + const metrics = font.getMetrics(); + const textHeight = metrics.descent - metrics.ascent; + return { x: textWidth / 2, y: textHeight / 2 }; + }, [font]); + + const title = transforms[sel(TITLE_ID)]!; + + return ( + + + setSelected(null)} + width={width} + height={height} + gestureEnabled + > + {QUADRANTS.map((quadrant) => { + const t = transforms[sel(quadrant.id)]!; + return ( + { + setSelected(sel(quadrant.id)); + e.cancelBubble = true; + }} + onTransformEnd={(e: EventObject) => + commit(sel(quadrant.id), e.evt as TransformResult) + } + /> + ); + })} + + {font && titleOffset && ( + { + setSelected(sel(TITLE_ID)); + e.cancelBubble = true; + }} + onTransformEnd={(e: EventObject) => + commit(sel(TITLE_ID), e.evt as TransformResult) + } + /> + )} + + { + if (selected) commit(selected, e); + }} + /> + + + + + ); +} + +const styles = StyleSheet.create({ + board: { flex: 1 }, +}); From 719d923c10db4a6f511429d4f235078ca9b2eb02 Mon Sep 17 00:00:00 2001 From: adithyavis Date: Tue, 14 Jul 2026 00:07:58 +0530 Subject: [PATCH 02/16] feat: portal example --- example/app/portal.tsx | 402 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 402 insertions(+) create mode 100644 example/app/portal.tsx diff --git a/example/app/portal.tsx b/example/app/portal.tsx new file mode 100644 index 0000000..607d853 --- /dev/null +++ b/example/app/portal.tsx @@ -0,0 +1,402 @@ +import { useEffect, useState } from 'react'; +import { + Pressable, + StyleSheet, + Text as RNText, + View, + useWindowDimensions, +} from 'react-native'; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; +import { + Stage, + Layer, + Rect, + Portal, + type EventObject, + type TransformResult, + SnapGrid, +} from 'react-native-canvas-kit'; +import { DrawerButton } from '../src/DrawerButton'; +import { useTouchTracker, TouchRings } from '../src/TouchOverlay'; +import { type NodeTransform } from '../src/constants'; + +const CARD_WIDTH = 320; +const POLL_ID = 'poll'; + +const buildInitial = (w: number, h: number): Record => ({ + [POLL_ID]: { + x: Math.round((w - CARD_WIDTH) / 2), + y: Math.round(h * 0.4), + scaleX: 1, + scaleY: 1, + rotation: 0, + }, +}); + +const TOOLS = ['Aa', '☺', '♪', '⋯']; + +type Choice = 'yes' | 'no'; +type RevealState = 'hidden' | 'correct' | 'wrong' | 'neutral'; + +const RESULTS: Record = { yes: 99, no: 1 }; +const CORRECT: Choice = 'yes'; + +export default function PortalScreen() { + const { width, height } = useWindowDimensions(); + const [picked, setPicked] = useState(null); + const [transforms, setTransforms] = useState(() => + buildInitial(width, height) + ); + const { gesture: touchGesture, touches } = useTouchTracker(); + + const revealed = picked !== null; + + const onOption = (choice: Choice) => setPicked(revealed ? null : choice); + + const revealStateFor = (choice: Choice): RevealState => { + if (!revealed) return 'hidden'; + if (choice === CORRECT) return 'correct'; + return choice === picked ? 'wrong' : 'neutral'; + }; + + const commit = (selector: string, t: TransformResult) => + setTransforms((prev) => ({ + ...prev, + [selector.replace('#', '')]: { + x: t.x, + y: t.y, + scaleX: t.scaleX, + scaleY: t.scaleY, + rotation: t.rotation, + }, + })); + + const interactive = (id: string) => { + const t = transforms[id]!; + return { + id, + x: t.x, + y: t.y, + scaleX: t.scaleX, + scaleY: t.scaleY, + rotation: t.rotation, + draggable: true, + scalable: true, + rotatable: true, + onTap: (e: EventObject) => { + e.cancelBubble = true; + }, + onTransformEnd: (e: EventObject) => + commit(`#${id}`, e.evt as TransformResult), + }; + }; + + return ( + + + + + + + + + + Is this a RN component? + + + onOption('yes')} + /> + onOption('no')} + /> + + + + + + + + + + + + + + {TOOLS.map((glyph) => ( + + {glyph} + + ))} + + + + + + ); +} + +function PollOption({ + label, + percent, + reveal, + onPress, +}: { + label: string; + percent: number; + reveal: RevealState; + onPress: () => void; +}) { + const revealed = reveal !== 'hidden'; + const fillStyle = + reveal === 'correct' + ? styles.fillCorrect + : reveal === 'wrong' + ? styles.fillWrong + : styles.fillNeutral; + + const grow = useSharedValue(0); + useEffect(() => { + grow.value = withTiming(revealed ? percent : 0, { + duration: 450, + easing: Easing.out(Easing.cubic), + }); + }, [grow, revealed, percent]); + const fillWidth = useAnimatedStyle(() => ({ width: `${grow.value}%` })); + + return ( + + + + {label} + {revealed ? ( + + {reveal === 'correct' ? ( + + + + ) : null} + {percent}% + + ) : null} + + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1 }, + stage: { backgroundColor: '#FF9100' }, + + cardShadow: { + width: CARD_WIDTH, + borderRadius: 22, + backgroundColor: '#FFFFFF', + shadowColor: '#000', + shadowOpacity: 0.25, + shadowRadius: 18, + shadowOffset: { width: 0, height: 10 }, + elevation: 8, + }, + cardClip: { + borderRadius: 22, + overflow: 'hidden', + backgroundColor: '#FFFFFF', + }, + header: { + backgroundColor: '#0A0A0A', + paddingVertical: 18, + paddingHorizontal: 20, + }, + title: { + color: '#FFFFFF', + fontSize: 18, + fontWeight: '700', + letterSpacing: 1, + padding: 0, + textAlign: 'center', + }, + body: { + padding: 12, + gap: 10, + }, + option: { + height: 54, + borderRadius: 14, + backgroundColor: '#ECECEC', + justifyContent: 'center', + overflow: 'hidden', + }, + resultFill: { + position: 'absolute', + left: 0, + top: 0, + bottom: 0, + }, + fillCorrect: { backgroundColor: 'rgba(34,197,94,0.32)' }, + fillWrong: { backgroundColor: 'rgba(239,68,68,0.28)' }, + fillNeutral: { backgroundColor: 'rgba(0,0,0,0.08)' }, + optionRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + }, + optionLabel: { + fontSize: 17, + fontWeight: '700', + color: '#111111', + }, + optionRight: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + optionPct: { + fontSize: 15, + fontWeight: '700', + color: '#111111', + }, + checkBadge: { + width: 22, + height: 22, + borderRadius: 11, + backgroundColor: '#22C55E', + alignItems: 'center', + justifyContent: 'center', + }, + checkGlyph: { + color: '#FFFFFF', + fontSize: 13, + fontWeight: '800', + }, + + topBar: { + position: 'absolute', + top: 52, + left: 0, + right: 0, + flexDirection: 'row', + justifyContent: 'flex-end', + paddingHorizontal: 16, + }, + toolRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + tool: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: 'rgba(0,0,0,0.28)', + alignItems: 'center', + justifyContent: 'center', + }, + colorTool: { + backgroundColor: '#FFB300', + borderWidth: 3, + borderColor: '#FFFFFF', + }, + toolGlyph: { + color: '#FFFFFF', + fontSize: 18, + fontWeight: '700', + }, + + bottomBar: { + position: 'absolute', + left: 16, + right: 16, + bottom: 34, + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + audiencePill: { + flex: 1, + height: 52, + borderRadius: 26, + backgroundColor: 'rgba(0,0,0,0.35)', + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 8, + gap: 10, + }, + avatar: { + width: 34, + height: 34, + borderRadius: 17, + backgroundColor: '#FFFFFF', + }, + starBadge: { + width: 34, + height: 34, + borderRadius: 17, + backgroundColor: '#22C55E', + alignItems: 'center', + justifyContent: 'center', + }, + starGlyph: { + color: '#FFFFFF', + fontSize: 18, + }, + audienceText: { + color: '#FFFFFF', + fontSize: 15, + fontWeight: '600', + }, + nextButton: { + width: 52, + height: 52, + borderRadius: 26, + backgroundColor: '#FFFFFF', + borderWidth: 3, + borderColor: '#FF2D87', + alignItems: 'center', + justifyContent: 'center', + }, + nextGlyph: { + color: '#111111', + fontSize: 22, + fontWeight: '700', + }, +}); From 4203642b2de54ec4abc9b6e4403fadb869beceb3 Mon Sep 17 00:00:00 2001 From: adithyavis Date: Tue, 14 Jul 2026 00:08:36 +0530 Subject: [PATCH 03/16] feat: canva example --- example/src/CanvaChrome.tsx | 486 ++++++++++++++++++++++++++++++++++++ 1 file changed, 486 insertions(+) create mode 100644 example/src/CanvaChrome.tsx diff --git a/example/src/CanvaChrome.tsx b/example/src/CanvaChrome.tsx new file mode 100644 index 0000000..dd348f6 --- /dev/null +++ b/example/src/CanvaChrome.tsx @@ -0,0 +1,486 @@ +import { useState, type ReactNode } from 'react'; +import { + Pressable, + StyleSheet, + Text, + View, + type LayoutChangeEvent, +} from 'react-native'; +import { + Ionicons, + MaterialCommunityIcons, + MaterialIcons, +} from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { DrawerActions } from '@react-navigation/native'; +import { useNavigation } from 'expo-router'; + +const HEADER_TINT = '#ffffff'; +const HEADER_DISABLED = 'rgba(255, 255, 255, 0.45)'; +const CANVA_PURPLE = '#8B3DFF'; +const GRADIENT_START = [0, 196, 204]; +const GRADIENT_END = [125, 42, 232]; +const GRADIENT_STEPS = 28; + +interface BoardSize { + width: number; + height: number; +} + +interface CanvaChromeProps { + hasSelection: boolean; + onConfirmSelection: () => void; + children: (size: BoardSize) => ReactNode; +} + +export function CanvaChrome({ + hasSelection, + onConfirmSelection, + children, +}: CanvaChromeProps) { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const [board, setBoard] = useState({ width: 0, height: 0 }); + + const onBoardLayout = (event: LayoutChangeEvent) => { + const { width, height } = event.nativeEvent.layout; + setBoard((prev) => + prev.width === width && prev.height === height ? prev : { width, height } + ); + }; + + return ( + + + + + navigation.dispatch(DrawerActions.openDrawer())} + hitSlop={8} + > + + + + + + + + + + + 1 + + + + + + + + + + {board.width > 0 && children(board)} + + + {hasSelection && ( + + + + + + + + + + + + + )} + + + + + + + + + + + + + {hasSelection ? ( + + + } + label="Edit" + /> + Ff} + label="Font" + /> + H} + label="Text styles" + /> + aA} + label="Font size" + /> + + } + label="Color" + /> + + + + + ) : ( + + } + label="Templates" + /> + + } + label="Elements" + /> + } + label="Text" + /> + + } + label="Gallery" + /> + + } + label="Brand" + pro + /> + + } + label="Upload" + /> + + )} + + + ); +} + +function GradientFill() { + const segments = []; + for (let step = 0; step < GRADIENT_STEPS; step += 1) { + const ratio = step / (GRADIENT_STEPS - 1); + const red = Math.round( + GRADIENT_START[0]! + (GRADIENT_END[0]! - GRADIENT_START[0]!) * ratio + ); + const green = Math.round( + GRADIENT_START[1]! + (GRADIENT_END[1]! - GRADIENT_START[1]!) * ratio + ); + const blue = Math.round( + GRADIENT_START[2]! + (GRADIENT_END[2]! - GRADIENT_START[2]!) * ratio + ); + segments.push( + + ); + } + return ( + + {segments} + + ); +} + +function TextTool({ + glyph, + label, + pro, +}: { + glyph: ReactNode; + label: string; + pro?: boolean; +}) { + return ( + + + {glyph} + {pro && ( + + )} + + + {label} + + + ); +} + +function PageThumb({ + number, + active, + label, + heading, +}: { + number: string; + active?: boolean; + label?: string; + heading?: boolean; +}) { + return ( + + + {label && {label}} + {heading && Add a heading} + + {number} + + ); +} + +function AddPageButton() { + return ( + + + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: '#e6e6ea' }, + header: { + paddingBottom: 10, + overflow: 'hidden', + }, + gradientRow: { flexDirection: 'row' }, + headerRow: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingTop: 4, + }, + headerIcon: { marginLeft: 18 }, + flexSpacer: { flex: 1 }, + badgeAnchor: { justifyContent: 'center' }, + badge: { + position: 'absolute', + top: -6, + right: -6, + minWidth: 16, + height: 16, + borderRadius: 8, + backgroundColor: '#ff4d4f', + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 3, + }, + badgeText: { color: '#ffffff', fontSize: 10, fontWeight: '700' }, + canvasArea: { + flex: 1, + padding: 14, + }, + board: { + flex: 1, + borderRadius: 8, + backgroundColor: '#ffffff', + borderWidth: StyleSheet.hairlineWidth, + borderColor: '#d0d0d6', + overflow: 'hidden', + shadowColor: '#000', + shadowOpacity: 0.15, + shadowRadius: 12, + shadowOffset: { width: 0, height: 4 }, + elevation: 4, + }, + floatingBar: { + position: 'absolute', + left: 0, + right: 0, + top: '26%', + alignItems: 'center', + }, + floatingPill: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#ffffff', + borderRadius: 26, + paddingHorizontal: 14, + paddingVertical: 10, + shadowColor: '#000', + shadowOpacity: 0.18, + shadowRadius: 10, + shadowOffset: { width: 0, height: 4 }, + elevation: 6, + }, + aiDot: { + width: 26, + height: 26, + borderRadius: 13, + backgroundColor: CANVA_PURPLE, + alignItems: 'center', + justifyContent: 'center', + }, + floatingIcon: { marginLeft: 18 }, + bottom: { + backgroundColor: '#ffffff', + }, + pagesStrip: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingTop: 12, + paddingBottom: 12, + }, + pageWrap: { alignItems: 'flex-start' }, + page: { + width: 96, + height: 60, + borderRadius: 8, + backgroundColor: '#ffffff', + borderWidth: 1, + borderColor: '#e2e2e6', + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + }, + pageActive: { borderWidth: 2, borderColor: CANVA_PURPLE }, + pageLabel: { color: '#1b1b1f', fontSize: 14, fontWeight: '700' }, + pageHeading: { color: '#9a9aa2', fontSize: 9, fontWeight: '700' }, + pageNumber: { color: '#5a5a63', fontSize: 12, marginTop: 4, marginLeft: 2 }, + addPage: { + width: 34, + height: 34, + borderRadius: 17, + backgroundColor: '#f0f0f3', + alignItems: 'center', + justifyContent: 'center', + marginHorizontal: 6, + marginBottom: 20, + }, + toolbar: { + flexDirection: 'row', + alignItems: 'flex-start', + justifyContent: 'space-between', + backgroundColor: '#ffffff', + paddingHorizontal: 14, + paddingTop: 12, + borderTopWidth: 1, + borderTopColor: '#ececef', + }, + tool: { alignItems: 'center', width: 62 }, + toolGlyph: { height: 26, justifyContent: 'center' }, + toolLabel: { color: '#3d3d47', fontSize: 11, marginTop: 5 }, + glyphSerif: { fontSize: 20, fontWeight: '600', color: '#1b1b1f' }, + glyphBold: { fontSize: 21, fontWeight: '800', color: '#1b1b1f' }, + glyphSize: { fontSize: 18, fontWeight: '700', color: '#1b1b1f' }, + proBadge: { position: 'absolute', top: -8, right: -12 }, + confirm: { + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: '#f0f0f3', + alignItems: 'center', + justifyContent: 'center', + }, +}); From 767030c67acfa5496c25514143d5f0408b23a031 Mon Sep 17 00:00:00 2001 From: adithyavis Date: Tue, 14 Jul 2026 00:09:43 +0530 Subject: [PATCH 04/16] feat: insta crop --- example/app/instagram-crop.tsx | 297 +++++++++++++++++++++++++++ example/src/CropOverlay.tsx | 76 +++++++ example/src/assets/crop/photo-1.jpg | Bin 0 -> 44799 bytes example/src/assets/crop/photo-10.jpg | Bin 0 -> 63478 bytes example/src/assets/crop/photo-11.jpg | Bin 0 -> 59771 bytes example/src/assets/crop/photo-12.jpg | Bin 0 -> 86190 bytes example/src/assets/crop/photo-13.jpg | Bin 0 -> 32286 bytes example/src/assets/crop/photo-14.jpg | Bin 0 -> 49602 bytes example/src/assets/crop/photo-15.jpg | Bin 0 -> 42855 bytes example/src/assets/crop/photo-2.jpg | Bin 0 -> 51991 bytes example/src/assets/crop/photo-3.jpg | Bin 0 -> 37861 bytes example/src/assets/crop/photo-4.jpg | Bin 0 -> 53454 bytes example/src/assets/crop/photo-5.jpg | Bin 0 -> 81560 bytes example/src/assets/crop/photo-6.jpg | Bin 0 -> 73052 bytes example/src/assets/crop/photo-7.jpg | Bin 0 -> 42567 bytes example/src/assets/crop/photo-8.jpg | Bin 0 -> 61033 bytes example/src/assets/crop/photo-9.jpg | Bin 0 -> 53355 bytes example/src/cropPhotos.ts | 22 ++ 18 files changed, 395 insertions(+) create mode 100644 example/app/instagram-crop.tsx create mode 100644 example/src/CropOverlay.tsx create mode 100644 example/src/assets/crop/photo-1.jpg create mode 100644 example/src/assets/crop/photo-10.jpg create mode 100644 example/src/assets/crop/photo-11.jpg create mode 100644 example/src/assets/crop/photo-12.jpg create mode 100644 example/src/assets/crop/photo-13.jpg create mode 100644 example/src/assets/crop/photo-14.jpg create mode 100644 example/src/assets/crop/photo-15.jpg create mode 100644 example/src/assets/crop/photo-2.jpg create mode 100644 example/src/assets/crop/photo-3.jpg create mode 100644 example/src/assets/crop/photo-4.jpg create mode 100644 example/src/assets/crop/photo-5.jpg create mode 100644 example/src/assets/crop/photo-6.jpg create mode 100644 example/src/assets/crop/photo-7.jpg create mode 100644 example/src/assets/crop/photo-8.jpg create mode 100644 example/src/assets/crop/photo-9.jpg create mode 100644 example/src/cropPhotos.ts diff --git a/example/app/instagram-crop.tsx b/example/app/instagram-crop.tsx new file mode 100644 index 0000000..9450dd3 --- /dev/null +++ b/example/app/instagram-crop.tsx @@ -0,0 +1,297 @@ +import { useEffect, useRef, useState } from 'react'; +import { + Image as RNImage, + Pressable, + ScrollView, + StyleSheet, + Text, + View, + useWindowDimensions, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { DrawerActions } from '@react-navigation/native'; +import { useNavigation } from 'expo-router'; +import { Asset } from 'expo-asset'; +import { + Stage, + Layer, + Image, + type EventObject, + type TransformResult, +} from 'react-native-canvas-kit'; +import { CROP_PHOTOS } from '../src/cropPhotos'; +import { CropOverlay } from '../src/CropOverlay'; +import { useTouchTracker, TouchRings } from '../src/TouchOverlay'; + +const MIN_SCALE = 1; +const MAX_SCALE = 4; + +const PAN_MARGIN = 48; +const SCALE_MARGIN = 0.25; +const BOUNCE_DURATION = 280; + +const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3); + +interface CropTransform { + x: number; + y: number; + scaleX: number; + scaleY: number; + rotation: number; +} + +const IDENTITY: CropTransform = { + x: 0, + y: 0, + scaleX: 1, + scaleY: 1, + rotation: 0, +}; + +const clamp = (value: number, min: number, max: number) => + Math.min(Math.max(value, min), max); + +function clampToViewport(t: TransformResult, size: number): CropTransform { + const scaleX = clamp(t.scaleX, MIN_SCALE, MAX_SCALE); + const scaleY = clamp(t.scaleY, MIN_SCALE, MAX_SCALE); + const scaledWidth = size * scaleX; + const scaledHeight = size * scaleY; + return { + x: clamp(t.x, size - scaledWidth, 0), + y: clamp(t.y, size - scaledHeight, 0), + scaleX, + scaleY, + rotation: 0, + }; +} + +export default function InstagramCropScreen() { + const { width } = useWindowDimensions(); + const insets = useSafeAreaInsets(); + const navigation = useNavigation(); + const cropSize = width; + const radius = width / 2; + + const [selectedId, setSelectedId] = useState(CROP_PHOTOS[0]!.id); + const [transforms, setTransforms] = useState>( + {} + ); + const { gesture: touchGesture, touches } = useTouchTracker(); + + const transform = transforms[selectedId] ?? IDENTITY; + const selected = CROP_PHOTOS.find((p) => p.id === selectedId)!; + + const bounceRef = useRef(null); + + const cancelBounce = () => { + if (bounceRef.current != null) { + cancelAnimationFrame(bounceRef.current); + bounceRef.current = null; + } + }; + + useEffect(() => cancelBounce, [selectedId]); + + const animateBounce = ( + id: string, + from: CropTransform, + to: CropTransform + ) => { + cancelBounce(); + let startTs: number | null = null; + const step = (ts: number) => { + if (startTs == null) startTs = ts; + const progress = Math.min((ts - startTs) / BOUNCE_DURATION, 1); + const eased = easeOutCubic(progress); + const lerp = (a: number, b: number) => a + (b - a) * eased; + setTransforms((prev) => ({ + ...prev, + [id]: { + x: lerp(from.x, to.x), + y: lerp(from.y, to.y), + scaleX: lerp(from.scaleX, to.scaleX), + scaleY: lerp(from.scaleY, to.scaleY), + rotation: 0, + }, + })); + if (progress < 1) { + bounceRef.current = requestAnimationFrame(step); + } else { + bounceRef.current = null; + } + }; + bounceRef.current = requestAnimationFrame(step); + }; + + const commit = (id: string, result: TransformResult) => { + const from: CropTransform = { + x: result.x, + y: result.y, + scaleX: result.scaleX, + scaleY: result.scaleY, + rotation: 0, + }; + const to = clampToViewport(result, cropSize); + const overshoot = + Math.abs(from.x - to.x) > 0.5 || + Math.abs(from.y - to.y) > 0.5 || + Math.abs(from.scaleX - to.scaleX) > 0.001 || + Math.abs(from.scaleY - to.scaleY) > 0.001; + setTransforms((prev) => ({ ...prev, [id]: from })); + if (overshoot) { + animateBounce(id, from, to); + } + }; + + const minX = cropSize * (1 - transform.scaleX) - PAN_MARGIN; + const minY = cropSize * (1 - transform.scaleY) - PAN_MARGIN; + + const thumbSize = width / 4; + + return ( + + + navigation.dispatch(DrawerActions.openDrawer())} + > + + + + Recents + + + + Done + + + + + + + + commit(selectedId, e.evt as TransformResult) + } + /> + + + + + + + + + + {CROP_PHOTOS.map((photo) => { + const active = photo.id === selectedId; + return ( + setSelectedId(photo.id)} + style={[styles.thumb, { width: thumbSize, height: thumbSize }]} + > + + {active ? : null} + + ); + })} + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: '#000000', + }, + topBar: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingBottom: 12, + }, + close: { + color: '#ffffff', + fontSize: 22, + fontWeight: '400', + }, + title: { + flexDirection: 'row', + alignItems: 'center', + }, + titleText: { + color: '#ffffff', + fontSize: 18, + fontWeight: '600', + }, + caret: { + color: '#ffffff', + fontSize: 14, + marginLeft: 6, + marginTop: -6, + }, + done: { + color: '#3897f0', + fontSize: 16, + fontWeight: '600', + }, + cropArea: { + width: '100%', + backgroundColor: '#000000', + }, + stage: { + backgroundColor: '#000000', + }, + grid: { + flex: 1, + backgroundColor: '#000000', + }, + gridContent: { + flexDirection: 'row', + flexWrap: 'wrap', + }, + thumb: { + padding: 1, + }, + thumbImage: { + width: '100%', + height: '100%', + }, + thumbActive: { + ...StyleSheet.absoluteFillObject, + borderWidth: 3, + borderColor: '#3897f0', + }, +}); diff --git a/example/src/CropOverlay.tsx b/example/src/CropOverlay.tsx new file mode 100644 index 0000000..cd51870 --- /dev/null +++ b/example/src/CropOverlay.tsx @@ -0,0 +1,76 @@ +import { useMemo } from 'react'; +import { StyleSheet } from 'react-native'; +import { + Canvas, + Path, + Group, + Skia, + FillType, +} from '@shopify/react-native-skia'; + +interface CropOverlayProps { + size: number; + radius: number; +} + +export function CropOverlay({ size, radius }: CropOverlayProps) { + const center = size / 2; + + const vignette = useMemo(() => { + const path = Skia.Path.Make(); + path.addRect(Skia.XYWHRect(0, 0, size, size)); + path.addCircle(center, center, radius); + path.setFillType(FillType.EvenOdd); + return path; + }, [size, center, radius]); + + const circle = useMemo(() => { + const path = Skia.Path.Make(); + path.addCircle(center, center, radius); + return path; + }, [center, radius]); + + const grid = useMemo(() => { + const path = Skia.Path.Make(); + const left = center - radius; + const top = center - radius; + const step = (radius * 2) / 3; + for (let i = 1; i < 3; i++) { + const x = left + step * i; + path.moveTo(x, top); + path.lineTo(x, top + radius * 2); + const y = top + step * i; + path.moveTo(left, y); + path.lineTo(left + radius * 2, y); + } + return path; + }, [center, radius]); + + return ( + + + + + + + + ); +} + +const styles = StyleSheet.create({ + canvas: { + position: 'absolute', + top: 0, + left: 0, + }, +}); diff --git a/example/src/assets/crop/photo-1.jpg b/example/src/assets/crop/photo-1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..fb354d964bb55750fca3b79932255537245f9ddf GIT binary patch literal 44799 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