diff --git a/docs/docs/canvas/canvas.md b/docs/docs/canvas/canvas.md new file mode 100644 index 0000000000..4db7a80c2f --- /dev/null +++ b/docs/docs/canvas/canvas.md @@ -0,0 +1,50 @@ +--- +id: canvas +title: Canvas +sidebar_label: Overview +slug: /canvas/overview +--- + +The Canvas component is the root of your Skia drawing. +You can treat it as a regular React Native view and assign a view style to it. +Behind the scenes, it is using its own React renderer. + +| Name | Type | Description. | +|:-----|:---------|:-----------------| +| style | `ViewStyle` | View style. | +| ref? | `Ref` | Reference to the `SkiaView` object | +| onTouch? | `TouchHandler` | Touch handler for the Canvas (see [touch handler](/docs/animations/overview#usetouchhandler)). | + +## Getting a Canvas Snapshot + +You can save your drawings as an image, using `makeImageSnapshot`. This method will return an [Image instance](/docs/images#instance-methods). This instance can be used to do anything: drawing it via the `` component, or being saved or shared using binary or base64 encoding. + +### Example + +```tsx twoslash +import {useEffect} from "react"; +import {Canvas, Image, useCanvasRef, Circle} from "@shopify/react-native-skia"; + +export const Demo = () => { + const ref = useCanvasRef(); + const onPress = useEffect(() => { + setTimeout(() => { + // you can pass an optional rectangle + // to only save part of the image + const image = ref.current?.makeImageSnapshot(); + if (image) { + // you can use image in an component + // Or save to file using encodeToBytes -> Uint8Array + const bytes = image.encodeToBytes(); + } + }, 1000) + }); + return ( + + + + ); +}; +``` + + diff --git a/docs/docs/getting-started/contexts.md b/docs/docs/canvas/contexts.md similarity index 98% rename from docs/docs/getting-started/contexts.md rename to docs/docs/canvas/contexts.md index 5c37241e5b..b967e9d4dc 100644 --- a/docs/docs/getting-started/contexts.md +++ b/docs/docs/canvas/contexts.md @@ -2,7 +2,7 @@ id: contexts title: Contexts sidebar_label: Contexts -slug: /getting-started/contexts +slug: /canvas/contexts --- React Native Skia is using its own React renderer. diff --git a/docs/docs/image.md b/docs/docs/image.md index 96d661e334..423d2715ac 100644 --- a/docs/docs/image.md +++ b/docs/docs/image.md @@ -7,23 +7,19 @@ slug: /images Images can be draw by specifying the output rectangle and how the image should fit into that rectangle. -| Name | Type | Description | -|:----------|:----------|:--------------------------------------------------------------| -| source | `require` or `string` | Source of the image or an HTTP(s) URL. | -| x | `number` | Left position of the destination image. | -| y | `number` | Right position of the destination image. | -| width | `number` | Width of the destination image. | -| height | `number` | Height of the destination image. | -| fit? | `Fit` | Method to make the image fit into the rectangle. Value can be `contain`, `fill`, `cover` `fitHeight`, `fitWidth`, `scaleDown`, `none` (default is `contain`). | +| Name | Type | Description | +| :----- | :-------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| source | `require` or `string` | Source of the image or an HTTP(s) URL. | +| x | `number` | Left position of the destination image. | +| y | `number` | Right position of the destination image. | +| width | `number` | Width of the destination image. | +| height | `number` | Height of the destination image. | +| fit? | `Fit` | Method to make the image fit into the rectangle. Value can be `contain`, `fill`, `cover` `fitHeight`, `fitWidth`, `scaleDown`, `none` (default is `contain`). | ### Example ```tsx twoslash -import { - Canvas, - Image, - useImage -} from "@shopify/react-native-skia"; +import { Canvas, Image, useImage } from "@shopify/react-native-skia"; const ImageDemo = () => { // Alternatively, you can pass an image URL directly @@ -31,7 +27,7 @@ const ImageDemo = () => { const source = useImage(require("../../assets/oslo.jpg")); return ( - { source && ( + {source && ( { y={0} width={256} height={256} - />) - } + /> + )} ); }; @@ -73,3 +69,12 @@ const ImageDemo = () => { ### fit="none" ![fit="none"](assets/images/none.png) + +## Instance Methods + +| Name | Description | +| :---------------- | :------------------------------------------------------------------------------------ | +| height | Returns the possibly scaled height of the image. | +| width | Returns the possibly scaled width of the image. | +| encodeToBytes | Encodes Image pixels, returning result as UInt8Array | +| encodeToBase64 | Encodes Image pixels, returning result as a base64 encoded string | diff --git a/docs/sidebars.js b/docs/sidebars.js index 17723dda35..44fff6c410 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -18,14 +18,16 @@ const sidebars = { collapsed: false, type: "category", label: "Getting started", - items: [ - "getting-started/installation", - "getting-started/hello-world", - "getting-started/contexts", - ], + items: ["getting-started/installation", "getting-started/hello-world"], }, { - collapsed: false, + collapsed: true, + type: "category", + label: "Canvas", + items: ["canvas/canvas", "canvas/contexts"], + }, + { + collapsed: true, type: "category", label: "Paint", items: ["paint/overview", "paint/properties"], @@ -36,19 +38,19 @@ const sidebars = { id: "group", }, { - collapsed: false, + collapsed: true, type: "category", label: "Image", items: ["image", "image-svg"], }, { - collapsed: false, + collapsed: true, type: "category", label: "Text", items: ["text/fonts", "text/text"], }, { - collapsed: false, + collapsed: true, type: "category", label: "Shaders", items: [ @@ -60,13 +62,13 @@ const sidebars = { ], }, { - collapsed: false, + collapsed: true, type: "category", label: "Effects", items: ["mask-filters", "color-filters", "image-filters", "path-effects"], }, { - collapsed: false, + collapsed: true, type: "category", label: "Shapes", items: [ @@ -77,7 +79,7 @@ const sidebars = { ], }, { - collapsed: false, + collapsed: true, type: "category", label: "Animations", items: ["animations/overview", "animations/reanimated"], diff --git a/example/ios/RNSkia.xcodeproj/project.pbxproj b/example/ios/RNSkia.xcodeproj/project.pbxproj index f1d303fbe4..251dc4134b 100644 --- a/example/ios/RNSkia.xcodeproj/project.pbxproj +++ b/example/ios/RNSkia.xcodeproj/project.pbxproj @@ -500,6 +500,7 @@ PRODUCT_NAME = RNSkia; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; }; name = Debug; @@ -525,6 +526,7 @@ PRODUCT_BUNDLE_IDENTIFIER = org.shopify.reactnative.skia.example; PRODUCT_NAME = RNSkia; SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; }; name = Release; @@ -687,4 +689,4 @@ /* End XCConfigurationList section */ }; rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; -} \ No newline at end of file +} diff --git a/example/ios/RNSkia/Info.plist b/example/ios/RNSkia/Info.plist index aa3e0b40b2..cce40a55d1 100644 --- a/example/ios/RNSkia/Info.plist +++ b/example/ios/RNSkia/Info.plist @@ -47,6 +47,8 @@ armv7 + UIRequiresFullScreen + UISupportedInterfaceOrientations UIInterfaceOrientationPortrait diff --git a/example/src/Examples/Drawing/Context/functions/findClosestElementToPoint.ts b/example/src/Examples/Drawing/Context/functions/findClosestElementToPoint.ts new file mode 100644 index 0000000000..b2c81c5df9 --- /dev/null +++ b/example/src/Examples/Drawing/Context/functions/findClosestElementToPoint.ts @@ -0,0 +1,46 @@ +import type { Point } from "@shopify/react-native-skia"; + +import type { DrawingElements } from "../types"; + +import { getBounds } from "./getBounds"; + +export const findClosestElementToPoint = ( + point: Point, + elements: DrawingElements +) => { + // Empty elements returns undefined + if (elements.length === 0) { + return undefined; + } + // Check if we any of the paths (in reverse top-down order) contains the point + for (let i = elements.length - 1; i >= 0; i--) { + if (elements[i].path.contains(point.x, point.y)) { + return elements[i]; + } + } + // If not, measure distance to the closest path + const distances = elements + .map((element) => { + const rect = getBounds(element); + // check if point is in rect + if ( + point.x >= rect.x - 10 && + point.x < rect.x + rect.width + 10 && + point.y >= rect.y - 10 && + point.y < rect.y + rect.height + 10 + ) { + // Find distance from click to center of element + var dx = Math.max(rect.x - point.x, point.x - (rect.x + rect.width)); + var dy = Math.max(rect.y - point.y, point.y - (rect.y + rect.height)); + return { ...element, distance: Math.sqrt(dx * dx + dy * dy) }; + } else { + return { ...element, distance: Number.MAX_VALUE }; + } + }) + .sort((a, b) => a.distance - b.distance); + + return elements.find( + (el) => + el.path === distances[0].path && distances[0].distance < Number.MAX_VALUE + ); +}; diff --git a/example/src/Examples/Drawing/Context/functions/findElementsInRect.ts b/example/src/Examples/Drawing/Context/functions/findElementsInRect.ts new file mode 100644 index 0000000000..f2aa9bd136 --- /dev/null +++ b/example/src/Examples/Drawing/Context/functions/findElementsInRect.ts @@ -0,0 +1,34 @@ +import type { IRect } from "@shopify/react-native-skia"; + +import type { DrawingElements } from "../types"; + +import { getBounds } from "./getBounds"; + +export const findElementsInRect = ( + rect: IRect, + elements: DrawingElements +): DrawingElements | undefined => { + const retVal: DrawingElements = []; + const normalizedRect = { + x: rect.width < 0 ? rect.x + rect.width : rect.x, + y: rect.height < 0 ? rect.y + rect.height : rect.y, + width: Math.abs(rect.width), + height: Math.abs(rect.height), + }; + elements.forEach((element) => { + const bounds = getBounds(element); + if ( + bounds.x >= normalizedRect.x && + bounds.x + bounds.width <= normalizedRect.x + normalizedRect.width && + bounds.y >= normalizedRect.y && + bounds.y + bounds.height <= normalizedRect.y + normalizedRect.height + ) { + retVal.push(element); + } + }); + + if (retVal.length > 0) { + return retVal; + } + return undefined; +}; diff --git a/example/src/Examples/Drawing/Context/functions/findResizeMode.ts b/example/src/Examples/Drawing/Context/functions/findResizeMode.ts new file mode 100644 index 0000000000..e9de393654 --- /dev/null +++ b/example/src/Examples/Drawing/Context/functions/findResizeMode.ts @@ -0,0 +1,48 @@ +import type { Point } from "@shopify/react-native-skia"; + +import type { DrawingElements, ResizeMode } from "../types"; + +import { getBoundingBox } from "./getBoundingBox"; + +const hitSlop = 8; + +export const findResizeMode = ( + point: Point, + selectedElements: DrawingElements +): ResizeMode | undefined => { + const bounds = getBoundingBox(selectedElements); + if (!bounds) { + return undefined; + } + + if ( + point.x >= bounds.x - hitSlop && + point.x <= bounds.x + hitSlop && + point.y >= bounds.y - hitSlop && + point.y <= bounds.y + hitSlop + ) { + return "topLeft"; + } else if ( + point.x >= bounds.x + bounds.width - hitSlop && + point.x <= bounds.x + bounds.width + hitSlop && + point.y >= bounds.y - hitSlop && + point.y <= bounds.y + hitSlop + ) { + return "topRight"; + } else if ( + point.x >= bounds.x + bounds.width - hitSlop && + point.x <= bounds.x + bounds.width + hitSlop && + point.y >= bounds.y + bounds.height - hitSlop && + point.y <= bounds.y + bounds.height + hitSlop + ) { + return "bottomRight"; + } else if ( + point.x >= bounds.x - hitSlop && + point.x <= bounds.x + hitSlop && + point.y >= bounds.y + bounds.height - hitSlop && + point.y <= bounds.y + bounds.height + hitSlop + ) { + return "bottomLeft"; + } + return undefined; +}; diff --git a/example/src/Examples/Drawing/Context/functions/getBoundingBox.ts b/example/src/Examples/Drawing/Context/functions/getBoundingBox.ts new file mode 100644 index 0000000000..e5502ae790 --- /dev/null +++ b/example/src/Examples/Drawing/Context/functions/getBoundingBox.ts @@ -0,0 +1,36 @@ +import type { DrawingElements } from "../types"; + +import { getBounds } from "./getBounds"; + +export const getBoundingBox = (elements: DrawingElements) => { + if (elements.length === 0) { + return undefined; + } + + const bb = { + x: Number.MAX_VALUE, + y: Number.MAX_VALUE, + right: Number.MIN_VALUE, + bottom: Number.MIN_VALUE, + }; + + for (let i = 0; i < elements.length; i++) { + const element = elements[i]; + const bounds = getBounds(element); + + if (bounds.x < bb.x) { + bb.x = bounds.x; + } + if (bounds.y < bb.y) { + bb.y = bounds.y; + } + if (bounds.x + bounds.width > bb.right) { + bb.right = bounds.x + bounds.width; + } + if (bounds.y + bounds.height > bb.bottom) { + bb.bottom = bounds.y + bounds.height; + } + } + + return { x: bb.x, y: bb.y, width: bb.right - bb.x, height: bb.bottom - bb.y }; +}; diff --git a/example/src/Examples/Drawing/Context/functions/getBounds.ts b/example/src/Examples/Drawing/Context/functions/getBounds.ts new file mode 100644 index 0000000000..d71ce3ae0a --- /dev/null +++ b/example/src/Examples/Drawing/Context/functions/getBounds.ts @@ -0,0 +1,7 @@ +import type { IRect } from "@shopify/react-native-skia"; + +import type { DrawingElement } from "../types"; + +export const getBounds = (element: DrawingElement): IRect => { + return element.path.getBounds() || element.path.computeTightBounds(); +}; diff --git a/example/src/Examples/Drawing/Context/functions/index.ts b/example/src/Examples/Drawing/Context/functions/index.ts new file mode 100644 index 0000000000..ec2f2693e2 --- /dev/null +++ b/example/src/Examples/Drawing/Context/functions/index.ts @@ -0,0 +1,7 @@ +export * from "./getBounds"; +export * from "./findClosestElementToPoint"; +export * from "./findElementsInRect"; +export * from "./findResizeMode"; +export * from "./resizeElements"; +export * from "./pointInRect"; +export * from "./getBoundingBox"; diff --git a/example/src/Examples/Drawing/Context/functions/pointInRect.ts b/example/src/Examples/Drawing/Context/functions/pointInRect.ts new file mode 100644 index 0000000000..c2061fbc11 --- /dev/null +++ b/example/src/Examples/Drawing/Context/functions/pointInRect.ts @@ -0,0 +1,10 @@ +import type { IRect, Point } from "@shopify/react-native-skia"; + +export const pointInRect = (p: Point, rect: IRect, offset = 10) => { + return ( + p.x + offset >= rect.x && + p.x - offset <= rect.x + rect.width && + p.y + offset >= rect.y && + p.y - offset <= rect.y + rect.height + ); +}; diff --git a/example/src/Examples/Drawing/Context/functions/resizeElements.ts b/example/src/Examples/Drawing/Context/functions/resizeElements.ts new file mode 100644 index 0000000000..6682ed754f --- /dev/null +++ b/example/src/Examples/Drawing/Context/functions/resizeElements.ts @@ -0,0 +1,68 @@ +import { Skia } from "@shopify/react-native-skia"; +import type { IRect } from "@shopify/react-native-skia"; + +import type { DrawingElements, ResizeMode } from "../types"; + +import { getBoundingBox } from "./getBoundingBox"; + +export const resizeElementsBy = ( + sx: number, + sy: number, + resizeMode: ResizeMode | undefined, + elements: DrawingElements +) => { + const source = getBoundingBox(elements); + if (source === undefined) { + return; + } + let dest: IRect; + switch (resizeMode) { + case "topLeft": + dest = resizeBounds(sx, sy, -sx, -sy, source); + break; + case "topRight": + dest = resizeBounds(0, sy, sx, -sy, source); + break; + case "bottomLeft": + dest = resizeBounds(sx, 0, -sx, sy, source); + break; + case "bottomRight": + dest = resizeBounds(0, 0, sx, sy, source); + break; + case undefined: + dest = resizeBounds(sx, sy, 0, 0, source); + } + + if (dest.width <= 0 || dest.height <= 0) { + return; + } + + const matrix = Skia.Matrix(); + const scaleX = dest.width / source.width; + const scaleY = dest.height / source.height; + matrix.setScaleX(scaleX); + matrix.setScaleY(scaleY); + matrix.setTranslateX(dest.x - source.x * scaleX); + matrix.setTranslateY(dest.y - source.y * scaleY); + + // use to scale elements + for (let i = 0; i < elements.length; i++) { + const element = elements[i]; + element.path.transform(matrix); + } +}; + +const resizeBounds = ( + x: number, + y: number, + r: number, + b: number, + bounds: IRect +) => { + return { + x: bounds.x + x, + y: bounds.y + y, + width: bounds.width + r, + height: bounds.height + b, + }; +}; diff --git a/example/src/Examples/Drawing/Context/index.ts b/example/src/Examples/Drawing/Context/index.ts new file mode 100644 index 0000000000..63e0058242 --- /dev/null +++ b/example/src/Examples/Drawing/Context/index.ts @@ -0,0 +1,3 @@ +export * from "./types"; +export * from "./functions"; +export * from "./shapes"; diff --git a/example/src/Examples/Drawing/Context/shapes/image.ts b/example/src/Examples/Drawing/Context/shapes/image.ts new file mode 100644 index 0000000000..4a31662ce7 --- /dev/null +++ b/example/src/Examples/Drawing/Context/shapes/image.ts @@ -0,0 +1,22 @@ +import type { IImage } from "@shopify/react-native-skia"; +import { Skia } from "@shopify/react-native-skia"; + +import type { DrawingElement } from "../types"; + +export const createImage = ( + x: number, + y: number, + image: IImage, + color: number, + size: number +): DrawingElement => { + const path = Skia.Path.Make(); + path.addRect({ x, y, width: 1, height: 1 }); + return { + type: "image", + image, + path: path, + color, + size, + }; +}; diff --git a/example/src/Examples/Drawing/Context/shapes/index.ts b/example/src/Examples/Drawing/Context/shapes/index.ts new file mode 100644 index 0000000000..8051521675 --- /dev/null +++ b/example/src/Examples/Drawing/Context/shapes/index.ts @@ -0,0 +1,4 @@ +export * from "./image"; +export * from "./path"; +export * from "./rect"; +export * from "./oval"; diff --git a/example/src/Examples/Drawing/Context/shapes/oval.ts b/example/src/Examples/Drawing/Context/shapes/oval.ts new file mode 100644 index 0000000000..ba3809fdd2 --- /dev/null +++ b/example/src/Examples/Drawing/Context/shapes/oval.ts @@ -0,0 +1,19 @@ +import { Skia } from "@shopify/react-native-skia"; + +import type { DrawingElement } from "../types"; + +export const createOval = ( + x: number, + y: number, + color: number, + size: number +): DrawingElement => { + const path = Skia.Path.Make(); + path.addOval({ x, y, width: 1, height: 1 }); + return { + type: "shape", + path, + color, + size, + }; +}; diff --git a/example/src/Examples/Drawing/Context/shapes/path.ts b/example/src/Examples/Drawing/Context/shapes/path.ts new file mode 100644 index 0000000000..90caa21615 --- /dev/null +++ b/example/src/Examples/Drawing/Context/shapes/path.ts @@ -0,0 +1,19 @@ +import { Skia } from "@shopify/react-native-skia"; + +import type { DrawingElement } from "../types"; + +export const createPath = ( + x: number, + y: number, + color: number, + size: number +): DrawingElement => { + const path = Skia.Path.Make(); + path.moveTo(x, y); + return { + type: "path", + path: path, + color, + size, + }; +}; diff --git a/example/src/Examples/Drawing/Context/shapes/rect.ts b/example/src/Examples/Drawing/Context/shapes/rect.ts new file mode 100644 index 0000000000..88b29e1de8 --- /dev/null +++ b/example/src/Examples/Drawing/Context/shapes/rect.ts @@ -0,0 +1,23 @@ +import { Skia } from "@shopify/react-native-skia"; + +import type { DrawingElement } from "../types"; + +export const createRect = ( + x: number, + y: number, + color: number, + size: number +): DrawingElement => { + const path = Skia.Path.Make(); + path.moveTo(x, y); + path.lineTo(x + 1, y); + path.lineTo(x + 1, y + 1); + path.lineTo(x, y + 1); + path.lineTo(x, y); + return { + type: "shape", + path, + color, + size, + }; +}; diff --git a/example/src/Examples/Drawing/Context/types.ts b/example/src/Examples/Drawing/Context/types.ts new file mode 100644 index 0000000000..30aba0ecdd --- /dev/null +++ b/example/src/Examples/Drawing/Context/types.ts @@ -0,0 +1,64 @@ +import type { IImage, IPath, IRect } from "@shopify/react-native-skia"; + +export type Tool = "draw" | "selection"; +export type Menu = "drawing" | "size" | "color"; +export type DrawingTool = "path" | "circle" | "rectangle" | "image"; + +export type UxState = { + menu: Menu | undefined; + drawingTool: DrawingTool; + activeTool: Tool; +}; + +export type UxCommands = { + toggleMenu: (menu: Menu | undefined) => void; + setTool: (tool: Tool) => void; + setDrawingTool: (mode: DrawingTool) => void; +}; + +export type UxContextType = { + state: UxState; + commands: UxCommands; + addListener: (listener: (state: UxState) => void) => () => void; +}; + +export type DrawingElementType = "path" | "shape" | "image"; + +export type DrawingElement = { + type: DrawingElementType; + color: number; + size: number; + path: IPath; +} & ( + | { type: "path" | "shape"; path: IPath } + | { type: "image"; path: IPath; image: IImage } +); + +export type DrawingElements = DrawingElement[]; + +export type ResizeMode = "topLeft" | "topRight" | "bottomLeft" | "bottomRight"; + +export type DrawState = { + color: number; + size: number; + elements: DrawingElements; + selectedElements: DrawingElements; + currentSelectionRect: IRect | undefined; + resizeMode: ResizeMode | undefined; +}; + +export type DrawCommands = { + setSize: (size: number) => void; + setColor: (color: number) => void; + addElement: (element: DrawingElement) => void; + setSelectedElements: (...elements: DrawingElements) => void; + setSelectionRect: (selection: IRect | undefined) => void; + deleteSelectedElements: () => void; + setResizeMode: (resizeMode: ResizeMode | undefined) => void; +}; + +export type DrawContextType = { + state: DrawState; + commands: DrawCommands; + addListener: (listener: (state: DrawState) => void) => () => void; +}; diff --git a/example/src/Examples/Drawing/DrawingCanvas.tsx b/example/src/Examples/Drawing/DrawingCanvas.tsx new file mode 100644 index 0000000000..581ff2d021 --- /dev/null +++ b/example/src/Examples/Drawing/DrawingCanvas.tsx @@ -0,0 +1,96 @@ +import React, { useEffect, useMemo, useState } from "react"; +import type { ViewStyle } from "react-native"; +import { + Canvas, + Path, + Fill, + Rect, + Group, + Paint, + DashPathEffect, + Image, +} from "@shopify/react-native-skia"; +import type { IRect, SkiaView } from "@shopify/react-native-skia"; + +import type { DrawingElements } from "./Context/types"; +import { SelectionFrame } from "./SelectionFrame"; +import { getBounds } from "./Context/functions"; +import { useTouchDrawing, useDrawContext } from "./Hooks"; + +type Props = { + innerRef: React.RefObject; + style: ViewStyle; +}; + +export const DrawingCanvas: React.FC = ({ innerRef, style }) => { + const drawContext = useDrawContext(); + const [elements, setElements] = useState([]); + const [selectedElements, setSelectedElements] = useState(); + const [selectionRect, setSelectionRect] = useState(); + + // Draw context updated effect + useEffect( + () => + drawContext.addListener((state) => { + setElements([...state.elements]); + setSelectionRect(state.currentSelectionRect); + setSelectedElements([...state.selectedElements]); + }), + [drawContext, innerRef] + ); + + const touchHandler = useTouchDrawing(); + + const elementComponents = useMemo(() => { + return elements.map((el, i) => { + switch (el.type) { + case "image": + return ( + getBounds(el)} + /> + ); + default: + return ( + + ); + } + }); + }, [elements]); + + return ( + + + {/** Render elements */} + {elementComponents} + {/** Render selected elements */} + {selectedElements ? ( + + ) : null} + {/** Render selection rectangle */} + {selectionRect ? ( + + + + + + + ) : null} + + ); +}; diff --git a/example/src/Examples/Drawing/Hooks/index.ts b/example/src/Examples/Drawing/Hooks/index.ts new file mode 100644 index 0000000000..36cdc2975f --- /dev/null +++ b/example/src/Examples/Drawing/Hooks/index.ts @@ -0,0 +1,6 @@ +export * from "./useShareNavButton"; +export * from "./useDrawContext"; +export * from "./useDrawProvider"; +export * from "./useTouchDrawing"; +export * from "./useUxContext"; +export * from "./useUxProvider"; diff --git a/example/src/Examples/Drawing/Hooks/useDrawContext.tsx b/example/src/Examples/Drawing/Hooks/useDrawContext.tsx new file mode 100644 index 0000000000..07c74ea9dd --- /dev/null +++ b/example/src/Examples/Drawing/Hooks/useDrawContext.tsx @@ -0,0 +1,13 @@ +import { useContext } from "react"; + +import type { DrawContextType } from "../Context/types"; + +import { DrawContext } from "./useDrawProvider"; + +export const useDrawContext = (): DrawContextType => { + const context = useContext(DrawContext); + if (context === null) { + throw Error("Ux Context missing"); + } + return context!; +}; diff --git a/example/src/Examples/Drawing/Hooks/useDrawProvider.tsx b/example/src/Examples/Drawing/Hooks/useDrawProvider.tsx new file mode 100644 index 0000000000..4baa3d6297 --- /dev/null +++ b/example/src/Examples/Drawing/Hooks/useDrawProvider.tsx @@ -0,0 +1,85 @@ +import type { IRect } from "@shopify/react-native-skia"; +import { Skia } from "@shopify/react-native-skia"; +import React, { useMemo } from "react"; + +import * as constants from "../constants"; +import type { + DrawState, + DrawContextType, + DrawingElement, + DrawingElements, + ResizeMode, +} from "../Context/types"; + +export const DrawContext = React.createContext( + undefined +); + +const createDrawProviderValue = (): DrawContextType => { + const state: DrawState = { + size: constants.SizeConstants[1], + color: Skia.Color("#000000"), + elements: [], + selectedElements: [], + currentSelectionRect: undefined, + resizeMode: undefined, + }; + + const listeners = [] as ((state: DrawState) => void)[]; + const notifyListeners = (s: DrawState) => listeners.forEach((l) => l(s)); + + const commands = { + setColor: (color: number) => { + state.color = color; + // Update the selected elements + state.selectedElements.forEach((e) => (e.color = color)); + notifyListeners(state); + }, + setSize: (size: number) => { + state.size = size; + // Update the selected elements + state.selectedElements.forEach((e) => (e.size = size)); + notifyListeners(state); + }, + addElement: (element: DrawingElement) => { + state.elements.push(element); + notifyListeners(state); + }, + setSelectedElements: (...elements: DrawingElements) => { + state.selectedElements = elements; + notifyListeners(state); + }, + setSelectionRect: (rect: IRect | undefined) => { + state.currentSelectionRect = rect; + notifyListeners(state); + }, + deleteSelectedElements: () => { + state.elements = state.elements.filter( + (el) => !state.selectedElements.includes(el) + ); + state.selectedElements = []; + notifyListeners(state); + }, + setResizeMode: (resizeMode: ResizeMode | undefined) => { + state.resizeMode = resizeMode; + notifyListeners(state); + }, + }; + + return { + state, + commands, + addListener: (cb: (state: DrawState) => void) => { + listeners.push(cb); + return () => listeners.splice(listeners.indexOf(cb), 1); + }, + }; +}; + +export const useDrawProvider = () => { + const uxContext = useMemo(() => createDrawProviderValue(), []); + const retVal: React.FC = ({ children }) => ( + {children} + ); + return retVal; +}; diff --git a/example/src/Examples/Drawing/Hooks/useShareNavButton.tsx b/example/src/Examples/Drawing/Hooks/useShareNavButton.tsx new file mode 100644 index 0000000000..6af6304f1d --- /dev/null +++ b/example/src/Examples/Drawing/Hooks/useShareNavButton.tsx @@ -0,0 +1,37 @@ +import { useNavigation } from "@react-navigation/native"; +import type { SkiaView } from "@shopify/react-native-skia"; +import { ImageFormat } from "@shopify/react-native-skia"; +import React, { useCallback } from "react"; +import { Alert, Share } from "react-native"; + +import { ShareToolPath } from "../assets"; +import { PathToolbarItem } from "../Toolbar/Items"; + +export const useShareNavButton = (skiaViewRef: React.RefObject) => { + const handleShare = useCallback(() => { + const image = skiaViewRef.current?.makeImageSnapshot(); + if (image) { + const data = image.encodeToBase64(ImageFormat.JPEG, 100); + const url = `data:image/png;base64,${data}`; + Share.share({ + url, + title: "Drawing", + }).catch(() => { + Alert.alert("An error occurred when sharing the image."); + }); + } else { + Alert.alert( + "An error occurred when creating a snapshot of your drawing." + ); + } + }, [skiaViewRef]); + + const navigation = useNavigation(); + React.useLayoutEffect(() => { + navigation.setOptions({ + headerRight: () => ( + + ), + }); + }, [handleShare, navigation]); +}; diff --git a/example/src/Examples/Drawing/Hooks/useTouchDrawing.ts b/example/src/Examples/Drawing/Hooks/useTouchDrawing.ts new file mode 100644 index 0000000000..09976cd3ef --- /dev/null +++ b/example/src/Examples/Drawing/Hooks/useTouchDrawing.ts @@ -0,0 +1,180 @@ +import { useRef } from "react"; +import type { Point } from "@shopify/react-native-skia"; +import { useImage, useTouchHandler } from "@shopify/react-native-skia"; + +import { + findClosestElementToPoint, + findElementsInRect, + findResizeMode, + pointInRect, + resizeElementsBy, + createPath, + createRect, + createOval, + createImage, + getBoundingBox, +} from "../Context"; + +import { useDrawContext } from "./useDrawContext"; +import { useUxContext } from "./useUxContext"; + +const osloImg = require("../../../assets/card.png"); + +export const useTouchDrawing = () => { + const prevPointRef = useRef(); + const drawContext = useDrawContext(); + const uxContext = useUxContext(); + const oslo = useImage(osloImg); + + return useTouchHandler({ + onStart: ({ x, y }) => { + // Close any menus + uxContext.commands.toggleMenu(undefined); + switch (uxContext.state.activeTool) { + case "draw": { + const { color, size } = drawContext.state; + switch (uxContext.state.drawingTool) { + case "path": + drawContext.commands.addElement(createPath(x, y, color, size)); + break; + case "rectangle": + drawContext.commands.addElement(createRect(x, y, color, size)); + break; + case "circle": + drawContext.commands.addElement(createOval(x, y, color, size)); + break; + case "image": + drawContext.commands.addElement( + createImage(x, y, oslo!, color, size) + ); + break; + } + break; + } + case "selection": { + // Find the element closest to the point + const el = findClosestElementToPoint( + { x, y }, + drawContext.state.elements + ); + + // Check if we have clicked an element that is not part + // of the current selection + if (el && drawContext.state.selectedElements.length === 0) { + // Not part of selection - we'll select it + drawContext.commands.setSelectedElements(el); + // Reset the selection rectangle + drawContext.commands.setSelectionRect(undefined); + break; + } + + const bounds = getBoundingBox(drawContext.state.selectedElements); + + // Lets test to see if we have clicked inside the selection boundary + if (bounds && pointInRect({ x, y }, bounds)) { + // We have a selection and we have clicked it - let us calculate the + // selection mode - ie. which corner we are resizing from + drawContext.commands.setResizeMode( + findResizeMode({ x, y }, drawContext.state.selectedElements) + ); + } else { + // We didn't find an element at x/y, so we'll deselect existing + // elements and start a new selection - clear existing + if (el) { + drawContext.commands.setSelectedElements(el); + } else { + drawContext.commands.setSelectedElements(); + // Reset the selection rectangle + drawContext.commands.setSelectionRect({ + x, + y, + width: 0, + height: 0, + }); + } + } + } + } + prevPointRef.current = { x, y }; + }, + onActive: ({ x, y }) => { + switch (uxContext.state.activeTool) { + case "draw": { + if (drawContext.state.elements.length > 0) { + // Get current drawing object + const element = + drawContext.state.elements[drawContext.state.elements.length - 1]; + + switch (element.type) { + case "path": + // Calculate and add a smooth curve to the current path + const xMid = (prevPointRef.current!.x + x) / 2; + const yMid = (prevPointRef.current!.y + y) / 2; + element.path.quadTo( + prevPointRef.current!.x, + prevPointRef.current!.y, + xMid, + yMid + ); + break; + default: + resizeElementsBy( + x - prevPointRef.current!.x, + y - prevPointRef.current!.y, + "bottomRight", + [element] + ); + break; + } + } + break; + } + case "selection": { + if (drawContext.state.selectedElements.length > 0) { + // Resizing or translate selected elements (if any) + resizeElementsBy( + x - prevPointRef.current!.x, + y - prevPointRef.current!.y, + drawContext.state.resizeMode, + drawContext.state.selectedElements + ); + } else { + // No selection made - let us update the selection rect instead. + if (drawContext.state.currentSelectionRect) { + drawContext.commands.setSelectionRect({ + x: drawContext.state.currentSelectionRect!.x, + y: drawContext.state.currentSelectionRect!.y, + width: x - drawContext.state.currentSelectionRect!.x, + height: y - drawContext.state.currentSelectionRect!.y, + }); + } + } + } + } + prevPointRef.current = { x, y }; + }, + onEnd: () => { + switch (uxContext.state.activeTool) { + case "draw": { + // Do nothing on touch end when drawing + break; + } + case "selection": { + if (drawContext.state.currentSelectionRect) { + // Select elements in the rect + const elements = findElementsInRect( + drawContext.state.currentSelectionRect, + drawContext.state.elements + ); + // Set selected elements + if (elements) { + drawContext.commands.setSelectedElements(...elements); + } + // Clear selection rect + drawContext.commands.setSelectionRect(undefined); + } + } + } + }, + }); +}; diff --git a/example/src/Examples/Drawing/Hooks/useUxContext.ts b/example/src/Examples/Drawing/Hooks/useUxContext.ts new file mode 100644 index 0000000000..114567b8e9 --- /dev/null +++ b/example/src/Examples/Drawing/Hooks/useUxContext.ts @@ -0,0 +1,13 @@ +import { useContext } from "react"; + +import type { UxContextType } from "../Context"; + +import { UxContext } from "./useUxProvider"; + +export const useUxContext = (): UxContextType => { + const context = useContext(UxContext); + if (context === null) { + throw Error("Ux Context missing"); + } + return context!; +}; diff --git a/example/src/Examples/Drawing/Hooks/useUxProvider.tsx b/example/src/Examples/Drawing/Hooks/useUxProvider.tsx new file mode 100644 index 0000000000..bf614b0efd --- /dev/null +++ b/example/src/Examples/Drawing/Hooks/useUxProvider.tsx @@ -0,0 +1,58 @@ +import React, { useMemo } from "react"; + +import type { + UxContextType, + Menu, + Tool, + UxState, + DrawingTool, +} from "../Context"; + +export const UxContext = React.createContext( + undefined +); + +const createUxProviderValue = (): UxContextType => { + const state: UxState = { + menu: undefined, + drawingTool: "path", + activeTool: "draw", + }; + + const listeners = [] as ((state: UxState) => void)[]; + const notifyListeners = (s: UxState) => listeners.forEach((l) => l(s)); + + const commands = { + toggleMenu: (menu: Menu | undefined) => { + state.menu = state.menu === menu ? undefined : menu; + notifyListeners(state); + }, + setTool: (tool: Tool) => { + state.activeTool = tool; + state.menu = undefined; + notifyListeners(state); + }, + setDrawingTool: (drawingTool: DrawingTool) => { + state.drawingTool = drawingTool; + state.menu = undefined; + notifyListeners(state); + }, + }; + + return { + state, + commands, + addListener: (cb: (state: UxState) => void) => { + listeners.push(cb); + return () => listeners.splice(listeners.indexOf(cb), 1); + }, + }; +}; + +export const useUxProvider = () => { + const uxContext = useMemo(() => createUxProviderValue(), []); + const retVal: React.FC = ({ children }) => ( + {children} + ); + return retVal; +}; diff --git a/example/src/Examples/Drawing/SelectionFrame.tsx b/example/src/Examples/Drawing/SelectionFrame.tsx new file mode 100644 index 0000000000..b29ac92e39 --- /dev/null +++ b/example/src/Examples/Drawing/SelectionFrame.tsx @@ -0,0 +1,59 @@ +import React, { useRef } from "react"; +import type { IRect } from "@shopify/react-native-skia"; +import { Group, Rect } from "@shopify/react-native-skia"; + +import type { DrawingElements } from "./Context/types"; +import { getBoundingBox } from "./Context/functions/getBoundingBox"; +import { SelectionResizeHandle } from "./SelectionHandle"; + +type Props = { + selectedElements: DrawingElements; +}; + +const SelecctionHandleSize = 6; + +export const SelectionFrame: React.FC = ({ selectedElements }) => { + const boundingBoxRef = useRef(undefined); + return selectedElements.length > 0 ? ( + + {/** Rect around selected elements */} + { + // Update the cached bounding box to avoid having to + // recreate it every time. + boundingBoxRef.current = getBoundingBox(selectedElements); + return boundingBoxRef.current!; + }} + color="#4185F4" + strokeWidth={2} + style="stroke" + /> + boundingBoxRef.current!} + color="#4185F418" + style="fill" + /> + {/** Resize handles */} + boundingBoxRef.current!.x} + y={() => boundingBoxRef.current!.y} + size={SelecctionHandleSize} + /> + boundingBoxRef.current!.x + boundingBoxRef.current!.width} + y={() => boundingBoxRef.current!.y} + size={SelecctionHandleSize} + /> + boundingBoxRef.current!.x + boundingBoxRef.current!.width} + y={() => boundingBoxRef.current!.y + boundingBoxRef.current!.height} + size={SelecctionHandleSize} + /> + boundingBoxRef.current!.x} + y={() => boundingBoxRef.current!.y + boundingBoxRef.current!.height} + size={SelecctionHandleSize} + /> + + ) : null; +}; diff --git a/example/src/Examples/Drawing/SelectionHandle.tsx b/example/src/Examples/Drawing/SelectionHandle.tsx new file mode 100644 index 0000000000..fa32172ba9 --- /dev/null +++ b/example/src/Examples/Drawing/SelectionHandle.tsx @@ -0,0 +1,33 @@ +import { Group, Rect } from "@shopify/react-native-skia"; +import React from "react"; +type Props = { + x: () => number; + y: () => number; + size: number; +}; + +export const SelectionResizeHandle: React.FC = ({ x, y, size }) => { + return ( + + {/** Rect */} + x() - size / 2} + y={() => y() - size / 2} + width={size} + height={size} + color="#4185F4" + strokeWidth={4} + style="stroke" + /> + x() - size / 2} + y={() => y() - size / 2} + width={size} + height={size} + color="#FFF" + style="fill" + /> + {/** Resize handles */} + + ); +}; diff --git a/example/src/Examples/Drawing/Toolbar/BaseToolbar.tsx b/example/src/Examples/Drawing/Toolbar/BaseToolbar.tsx new file mode 100644 index 0000000000..3d00df2cd1 --- /dev/null +++ b/example/src/Examples/Drawing/Toolbar/BaseToolbar.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import type { ViewStyle } from "react-native"; +import { StyleSheet, View } from "react-native"; + +type Direction = "vertical" | "horizontal" | "square"; + +export type BaseToolbarProps = { + style?: ViewStyle; + mode?: Direction | undefined; +}; +export const BaseToolbar: React.FC = ({ + children, + mode = "horizontal", + style, +}) => { + return {children}; +}; + +const getStyleFromMode = (mode: Direction) => { + switch (mode) { + case "vertical": + return [styles.container, styles.verticalContainer]; + case "horizontal": + return styles.container; + default: + return [styles.container, styles.squareContainer]; + } +}; + +export const styles = StyleSheet.create({ + container: { + backgroundColor: "#FFF", + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingHorizontal: 14, + paddingVertical: 4, + borderRadius: 14, + borderWidth: StyleSheet.hairlineWidth, + borderColor: "#DDD", + shadowColor: "#000", + shadowOpacity: 0.3, + shadowRadius: 4, + shadowOffset: { width: 0, height: 5 }, + elevation: 4, + }, + verticalContainer: { + flexDirection: "column", + paddingHorizontal: 4, + paddingVertical: 14, + }, + squareContainer: { + paddingHorizontal: 14, + paddingVertical: 14, + }, +}); diff --git a/example/src/Examples/Drawing/Toolbar/ColorPicker.tsx b/example/src/Examples/Drawing/Toolbar/ColorPicker.tsx new file mode 100644 index 0000000000..53cc89d629 --- /dev/null +++ b/example/src/Examples/Drawing/Toolbar/ColorPicker.tsx @@ -0,0 +1,117 @@ +import { + Canvas, + canvas2Polar, + Circle, + LinearGradient, + Paint, + RoundedRect, + Shader, + ShaderLib, + Skia, + useTouchHandler, + vec, +} from "@shopify/react-native-skia"; +import React, { useEffect, useMemo, useState } from "react"; +import { StyleSheet, View } from "react-native"; + +import { polar2Color } from "../../Hue/Helpers"; +import { useDrawContext, useUxContext } from "../Hooks"; + +import type { BaseToolbarProps } from "./BaseToolbar"; +import { BaseToolbar } from "./BaseToolbar"; + +type Props = BaseToolbarProps; + +export const PickerHeight = 200; +const DarkGrayWidth = 30; + +const source = Skia.RuntimeEffect.Make(` +uniform float2 c; +uniform float r; + +${ShaderLib.Math} +${ShaderLib.Colors} + +half4 main(vec2 uv) { + float mag = distance(uv, c); + float theta = normalizeRad(canvas2Polar(uv, c).x); + return hsv2rgb(vec3(theta/TAU, mag/r, 1.0)); +}`)!; + +export const ColorPicker: React.FC = ({ style, mode }) => { + const uxContext = useUxContext(); + + const [visible, setVisible] = useState(uxContext.state.menu === "color"); + + useEffect( + () => + uxContext.addListener((state) => { + setVisible(state.menu === "color"); + }), + [uxContext] + ); + + const drawContext = useDrawContext(); + + const center = useMemo(() => vec(PickerHeight / 2, PickerHeight / 2), []); + const r = useMemo(() => PickerHeight / 2, []); + + const colorWheelTouchHandler = useTouchHandler({ + onActive: (pt) => { + const { theta, radius } = canvas2Polar(pt, center); + drawContext.commands.setColor(polar2Color(theta, Math.min(radius, r), r)); + }, + }); + + const darkGrayTouchHandler = useTouchHandler({ + onActive: (pt) => { + // calculate percent of pt.y where PickerHeight is 100% + const gray = (pt.y / PickerHeight) * 0xcc; + drawContext.commands.setColor( + Skia.Color(`rgba(${gray},${gray},${gray}, 1.0)`) + ); + }, + }); + + return visible ? ( + + {/** Black - Gray */} + + + + + + + + {/** Color wheel */} + + + + + + + + ) : null; +}; + +const styles = StyleSheet.create({ + darkToGray: { + width: DarkGrayWidth, + height: PickerHeight, + }, + colorWheel: { + width: "100%", + height: PickerHeight, + }, +}); diff --git a/example/src/Examples/Drawing/Toolbar/DrawingToolMenu.tsx b/example/src/Examples/Drawing/Toolbar/DrawingToolMenu.tsx new file mode 100644 index 0000000000..5a8978f45f --- /dev/null +++ b/example/src/Examples/Drawing/Toolbar/DrawingToolMenu.tsx @@ -0,0 +1,56 @@ +import React, { useEffect, useState } from "react"; + +import { + CircleToolPath, + ImageToolPath, + PenToolPath, + RectToolPath, +} from "../assets"; +import { useUxContext } from "../Hooks"; + +import type { BaseToolbarProps } from "./BaseToolbar"; +import { BaseToolbar } from "./BaseToolbar"; +import { PathToolbarItem } from "./Items"; + +export const DrawingToolMenu: React.FC = ({ + style, + mode, +}) => { + const uxContext = useUxContext(); + const [visible, setVisible] = useState(uxContext.state.menu === "drawing"); + const [drawingTool, setDrawingTool] = useState(uxContext.state.drawingTool); + + useEffect( + () => + uxContext.addListener((state) => { + setVisible(state.menu === "drawing"); + setDrawingTool(state.drawingTool); + }), + [uxContext] + ); + + return visible ? ( + + uxContext.commands.setDrawingTool("image")} + /> + uxContext.commands.setDrawingTool("rectangle")} + /> + uxContext.commands.setDrawingTool("circle")} + /> + uxContext.commands.setDrawingTool("path")} + /> + + ) : null; +}; diff --git a/example/src/Examples/Drawing/Toolbar/Items/BaseToolbarItem.tsx b/example/src/Examples/Drawing/Toolbar/Items/BaseToolbarItem.tsx new file mode 100644 index 0000000000..d44db65ce5 --- /dev/null +++ b/example/src/Examples/Drawing/Toolbar/Items/BaseToolbarItem.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { TouchableOpacity, View } from "react-native"; + +import { styles } from "./styles"; + +export type BaseToolbarItemProps = { + selected?: boolean; + disabled?: boolean; + onPress?: () => void; +}; + +export const BaseToolbarItem: React.FC = ({ + selected, + disabled, + onPress, + children, +}) => { + return disabled ? ( + + {children} + + ) : ( + + {children} + + ); +}; diff --git a/example/src/Examples/Drawing/Toolbar/Items/ColorToolbarItem.tsx b/example/src/Examples/Drawing/Toolbar/Items/ColorToolbarItem.tsx new file mode 100644 index 0000000000..7d2f110532 --- /dev/null +++ b/example/src/Examples/Drawing/Toolbar/Items/ColorToolbarItem.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import { Canvas, Circle } from "@shopify/react-native-skia"; + +import type { BaseToolbarItemProps } from "./BaseToolbarItem"; +import { BaseToolbarItem } from "./BaseToolbarItem"; +import { styles } from "./styles"; + +type Props = BaseToolbarItemProps & { + color: number; +}; + +export const ColorToolbarItem: React.FC = ({ + color, + selected, + onPress, +}) => { + return ( + + + ctx.width / 2} + cy={(ctx) => ctx.height / 2} + r={(ctx) => ctx.width / 2} + color={color} + style="fill" + /> + + + ); +}; diff --git a/example/src/Examples/Drawing/Toolbar/Items/SizeToolbarItem.tsx b/example/src/Examples/Drawing/Toolbar/Items/SizeToolbarItem.tsx new file mode 100644 index 0000000000..3257f67814 --- /dev/null +++ b/example/src/Examples/Drawing/Toolbar/Items/SizeToolbarItem.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import { Canvas, Line } from "@shopify/react-native-skia"; + +import type { BaseToolbarItemProps } from "./BaseToolbarItem"; +import { BaseToolbarItem } from "./BaseToolbarItem"; +import { styles } from "./styles"; + +type Props = BaseToolbarItemProps & { + size: number; +}; + +export const SizeToolbarItem: React.FC = ({ + size, + selected, + onPress, +}) => { + return ( + + + ({ x: ctx.width / 2, y: 0 })} + p2={(ctx) => ({ x: ctx.width / 2, y: ctx.height })} + strokeWidth={size} + color={"#000"} + /> + + + ); +}; diff --git a/example/src/Examples/Drawing/Toolbar/Items/SkiaPathToolbarItem.tsx b/example/src/Examples/Drawing/Toolbar/Items/SkiaPathToolbarItem.tsx new file mode 100644 index 0000000000..3f76b7d91b --- /dev/null +++ b/example/src/Examples/Drawing/Toolbar/Items/SkiaPathToolbarItem.tsx @@ -0,0 +1,50 @@ +import React, { useCallback, useMemo } from "react"; +import type { IPath } from "@shopify/react-native-skia"; +import { Canvas, Group, Path } from "@shopify/react-native-skia"; + +import type { BaseToolbarItemProps } from "./BaseToolbarItem"; +import { BaseToolbarItem } from "./BaseToolbarItem"; +import { styles } from "./styles"; + +type Props = BaseToolbarItemProps & { + path: IPath; +}; + +export const PathToolbarItem: React.FC = ({ + path, + disabled, + selected, + onPress, +}) => { + const bounds = useMemo(() => path.computeTightBounds(), [path]); + const getTransform = useCallback( + (width: number, height: number) => { + // Calculate the transform for the path as a function of + // the canvas size and the path size. + const offset = { x: bounds.x, y: bounds.y }; + const factor = Math.min( + (width / (bounds.width + offset.x)) * 0.8, + (height / (bounds.height + offset.y)) * 0.8 + ); + return [ + { translateX: width / 2 - ((bounds.width + offset.x) * factor) / 2 }, + { + translateY: height / 2 - ((bounds.height + offset.y) * factor) / 2, + }, + { scaleX: factor }, + { scaleY: factor }, + ]; + }, + [bounds.height, bounds.width, bounds.x, bounds.y] + ); + + return ( + + + getTransform(ctx.width, ctx.height)}> + + + + + ); +}; diff --git a/example/src/Examples/Drawing/Toolbar/Items/index.ts b/example/src/Examples/Drawing/Toolbar/Items/index.ts new file mode 100644 index 0000000000..9aadb8005d --- /dev/null +++ b/example/src/Examples/Drawing/Toolbar/Items/index.ts @@ -0,0 +1,4 @@ +export * from "./BaseToolbarItem"; +export * from "./SkiaPathToolbarItem"; +export * from "./SizeToolbarItem"; +export * from "./ColorToolbarItem"; diff --git a/example/src/Examples/Drawing/Toolbar/Items/styles.ts b/example/src/Examples/Drawing/Toolbar/Items/styles.ts new file mode 100644 index 0000000000..83120cf6c5 --- /dev/null +++ b/example/src/Examples/Drawing/Toolbar/Items/styles.ts @@ -0,0 +1,27 @@ +import { StyleSheet } from "react-native"; + +export const ToolbarItemSize = 26; + +export const styles = StyleSheet.create({ + container: { + padding: 8, + alignItems: "center", + justifyContent: "center", + }, + toolbarItem: { + width: ToolbarItemSize, + height: ToolbarItemSize, + }, + colorItem: { + width: ToolbarItemSize, + height: ToolbarItemSize, + borderRadius: ToolbarItemSize / 2, + }, + selected: { + backgroundColor: "#CCC", + borderRadius: 4, + }, + disabled: { + opacity: 0.4, + }, +}); diff --git a/example/src/Examples/Drawing/Toolbar/MainToolbar.tsx b/example/src/Examples/Drawing/Toolbar/MainToolbar.tsx new file mode 100644 index 0000000000..7cbda984f1 --- /dev/null +++ b/example/src/Examples/Drawing/Toolbar/MainToolbar.tsx @@ -0,0 +1,112 @@ +import React, { useCallback, useEffect, useState } from "react"; +import type { ViewStyle } from "react-native"; + +import { + CircleToolPath, + DeleteToolPath, + ImageToolPath, + PenToolPath, + RectToolPath, + SelectToolPath, +} from "../assets"; +import type { DrawingTool } from "../Context/types"; +import { useDrawContext, useUxContext } from "../Hooks"; + +import { BaseToolbar } from "./BaseToolbar"; +import { ColorToolbarItem, PathToolbarItem, SizeToolbarItem } from "./Items"; + +type Props = { + style: ViewStyle; +}; +export const MainToolbar: React.FC = ({ style }) => { + const uxContext = useUxContext(); + const drawContext = useDrawContext(); + + const [activeTool, setActiveTool] = useState(uxContext.state.activeTool); + const [drawingTool, setDrawingTool] = useState(uxContext.state.drawingTool); + const [color, setColor] = useState(drawContext.state.color); + const [size, setSize] = useState(drawContext.state.size); + const [hasSelection, setHasSelection] = useState( + drawContext.state.selectedElements.length > 0 + ); + + useEffect(() => { + const unsubscribeUx = uxContext.addListener((state) => { + setActiveTool(state.activeTool); + setDrawingTool(state.drawingTool); + }); + const unsubscribeDraw = drawContext.addListener((state) => { + setColor(state.color); + setSize(state.size); + setHasSelection(state.selectedElements.length > 0); + }); + return () => { + unsubscribeDraw(); + unsubscribeUx(); + }; + }, [drawContext, uxContext]); + + const handleDrawingToolPressed = useCallback(() => { + if (uxContext.state.activeTool === "draw") { + uxContext.commands.toggleMenu("drawing"); + } else { + uxContext.commands.setTool("draw"); + } + drawContext.commands.setSelectedElements(); + }, [drawContext.commands, uxContext.commands, uxContext.state.activeTool]); + + const handleSelectionPressed = useCallback( + () => uxContext.commands.setTool("selection"), + [uxContext.commands] + ); + + const handleDelete = useCallback(() => { + uxContext.commands.toggleMenu(undefined); + drawContext.commands.deleteSelectedElements(); + }, [drawContext.commands, uxContext.commands]); + + const handleSizePressed = useCallback( + () => uxContext.commands.toggleMenu("size"), + [uxContext.commands] + ); + + const handleColorPressed = useCallback( + () => uxContext.commands.toggleMenu("color"), + [uxContext.commands] + ); + + return ( + + + + + + + + ); +}; + +const getPathFordrawingMode = (drawingMode: DrawingTool) => { + switch (drawingMode) { + case "path": + return PenToolPath!; + case "circle": + return CircleToolPath!; + case "rectangle": + return RectToolPath!; + case "image": + return ImageToolPath!; + } +}; diff --git a/example/src/Examples/Drawing/Toolbar/SizeMenu.tsx b/example/src/Examples/Drawing/Toolbar/SizeMenu.tsx new file mode 100644 index 0000000000..f2e9e16724 --- /dev/null +++ b/example/src/Examples/Drawing/Toolbar/SizeMenu.tsx @@ -0,0 +1,47 @@ +import React, { useCallback, useEffect, useState } from "react"; + +import { useDrawContext, useUxContext } from "../Hooks"; + +import type { BaseToolbarProps } from "./BaseToolbar"; +import { BaseToolbar } from "./BaseToolbar"; +import { SizeToolbarItem } from "./Items"; + +type Props = BaseToolbarProps & { + sizes: number[]; +}; + +export const SizeMenu: React.FC = ({ style, sizes, mode }) => { + const uxContext = useUxContext(); + const drawContext = useDrawContext(); + + const [visible, setVisible] = useState(uxContext.state.menu === "size"); + + useEffect( + () => + uxContext.addListener((state) => { + setVisible(state.menu === "size"); + }), + [uxContext] + ); + + const handleSelectSize = useCallback( + (s: number) => { + uxContext.commands.toggleMenu(undefined); + drawContext.commands.setSize(s); + }, + [drawContext.commands, uxContext.commands] + ); + + return visible ? ( + + {sizes.map((s) => ( + handleSelectSize(s)} + /> + ))} + + ) : null; +}; diff --git a/example/src/Examples/Drawing/Toolbar/index.ts b/example/src/Examples/Drawing/Toolbar/index.ts new file mode 100644 index 0000000000..7dd32e5532 --- /dev/null +++ b/example/src/Examples/Drawing/Toolbar/index.ts @@ -0,0 +1,4 @@ +export * from "./MainToolbar"; +export * from "./DrawingToolMenu"; +export * from "./SizeMenu"; +export * from "./ColorPicker"; diff --git a/example/src/Examples/Drawing/assets.tsx b/example/src/Examples/Drawing/assets.tsx new file mode 100644 index 0000000000..a45247b36f --- /dev/null +++ b/example/src/Examples/Drawing/assets.tsx @@ -0,0 +1,35 @@ +import { Skia } from "@shopify/react-native-skia"; + +export const PenToolPath = Skia.Path.MakeFromSVGString( + // eslint-disable-next-line max-len + "M497.9 74.16L437.8 14.06c-18.75-18.75-49.19-18.75-67.93 0l-56.53 56.55l127.1 128l56.56-56.55C516.7 123.3 516.7 92.91 497.9 74.16zM290.8 93.23l-259.7 259.7c-2.234 2.234-3.755 5.078-4.376 8.176l-26.34 131.7C-1.921 504 7.95 513.9 19.15 511.7l131.7-26.34c3.098-.6191 5.941-2.141 8.175-4.373l259.7-259.7L290.8 93.23z" +); + +export const DeleteToolPath = Skia.Path.MakeFromSVGString( + // eslint-disable-next-line max-len + "M432 80h-82.38l-34-56.75C306.1 8.827 291.4 0 274.6 0H173.4C156.6 0 141 8.827 132.4 23.25L98.38 80H16C7.125 80 0 87.13 0 96v16C0 120.9 7.125 128 16 128H32v320c0 35.35 28.65 64 64 64h256c35.35 0 64-28.65 64-64V128h16C440.9 128 448 120.9 448 112V96C448 87.13 440.9 80 432 80zM171.9 50.88C172.9 49.13 174.9 48 177 48h94c2.125 0 4.125 1.125 5.125 2.875L293.6 80H154.4L171.9 50.88zM352 464H96c-8.837 0-16-7.163-16-16V128h288v320C368 456.8 360.8 464 352 464zM224 416c8.844 0 16-7.156 16-16V192c0-8.844-7.156-16-16-16S208 183.2 208 192v208C208 408.8 215.2 416 224 416zM144 416C152.8 416 160 408.8 160 400V192c0-8.844-7.156-16-16-16S128 183.2 128 192v208C128 408.8 135.2 416 144 416zM304 416c8.844 0 16-7.156 16-16V192c0-8.844-7.156-16-16-16S288 183.2 288 192v208C288 408.8 295.2 416 304 416z" +); + +export const CircleToolPath = Skia.Path.MakeFromSVGString( + // eslint-disable-next-line max-len + "M256 0C114.6 0 0 114.6 0 256s114.6 256 256 256s256-114.6 256-256S397.4 0 256 0zM256 464c-114.7 0-208-93.31-208-208S141.3 48 256 48s208 93.31 208 208S370.7 464 256 464z" +); + +export const RectToolPath = Skia.Path.MakeFromSVGString( + "M464 48v416h-416v-416H464zM512 0H0v512h512V0z" +); + +export const ImageToolPath = Skia.Path.MakeFromSVGString( + // eslint-disable-next-line max-len + "M152 120c-26.51 0-48 21.49-48 48s21.49 48 48 48s48-21.49 48-48S178.5 120 152 120zM447.1 32h-384C28.65 32-.0091 60.65-.0091 96v320c0 35.35 28.65 64 63.1 64h384c35.35 0 64-28.65 64-64V96C511.1 60.65 483.3 32 447.1 32zM463.1 409.3l-136.8-185.9C323.8 218.8 318.1 216 312 216c-6.113 0-11.82 2.768-15.21 7.379l-106.6 144.1l-37.09-46.1c-3.441-4.279-8.934-6.809-14.77-6.809c-5.842 0-11.33 2.529-14.78 6.809l-75.52 93.81c0-.0293 0 .0293 0 0L47.99 96c0-8.822 7.178-16 16-16h384c8.822 0 16 7.178 16 16V409.3z" +); + +export const ShareToolPath = Skia.Path.MakeFromSVGString( + // eslint-disable-next-line max-len + "M384 352v64c0 17.67-14.33 32-32 32H96c-17.67 0-32-14.33-32-32v-64c0-17.67-14.33-32-32-32s-32 14.33-32 32v64c0 53.02 42.98 96 96 96h256c53.02 0 96-42.98 96-96v-64c0-17.67-14.33-32-32-32S384 334.3 384 352zM201.4 9.375l-128 128c-12.51 12.51-12.49 32.76 0 45.25c12.5 12.5 32.75 12.5 45.25 0L192 109.3V320c0 17.69 14.31 32 32 32s32-14.31 32-32V109.3l73.38 73.38c12.5 12.5 32.75 12.5 45.25 0s12.5-32.75 0-45.25l-128-128C234.1-3.125 213.9-3.125 201.4 9.375z" +); + +export const SelectToolPath = Skia.Path.MakeFromSVGString( + // eslint-disable-next-line max-len + "M3.29227 0.048984C3.47033 -0.032338 3.67946 -0.00228214 3.8274 0.125891L12.8587 7.95026C13.0134 8.08432 13.0708 8.29916 13.0035 8.49251C12.9362 8.68586 12.7578 8.81866 12.5533 8.82768L9.21887 8.97474L11.1504 13.2187C11.2648 13.47 11.1538 13.7664 10.9026 13.8808L8.75024 14.8613C8.499 14.9758 8.20255 14.8649 8.08802 14.6137L6.15339 10.3703L3.86279 12.7855C3.72196 12.934 3.50487 12.9817 3.31479 12.9059C3.1247 12.8301 3 12.6461 3 12.4414V0.503792C3 0.308048 3.11422 0.130306 3.29227 0.048984ZM4 1.59852V11.1877L5.93799 9.14425C6.05238 9.02363 6.21924 8.96776 6.38319 8.99516C6.54715 9.02256 6.68677 9.12965 6.75573 9.2809L8.79056 13.7441L10.0332 13.178L8.00195 8.71497C7.93313 8.56376 7.94391 8.38824 8.03072 8.24659C8.11753 8.10494 8.26903 8.01566 8.435 8.00834L11.2549 7.88397L4 1.59852Z" +); diff --git a/example/src/Examples/Drawing/constants.ts b/example/src/Examples/Drawing/constants.ts new file mode 100644 index 0000000000..042249632c --- /dev/null +++ b/example/src/Examples/Drawing/constants.ts @@ -0,0 +1 @@ +export const SizeConstants = [1, 2, 4, 8, 10]; diff --git a/example/src/Examples/Drawing/index.tsx b/example/src/Examples/Drawing/index.tsx index 65f8f9098b..e01fc5ab68 100644 --- a/example/src/Examples/Drawing/index.tsx +++ b/example/src/Examples/Drawing/index.tsx @@ -1,109 +1,73 @@ +/* eslint-disable react-native/no-unused-styles */ import React, { useMemo, useRef } from "react"; -import { Button, StyleSheet, View } from "react-native"; -import type { IPath } from "@shopify/react-native-skia"; -import { - Skia, - usePaint, - useDrawCallback, - useTouchHandler, - PaintStyle, - StrokeCap, - SkiaView, -} from "@shopify/react-native-skia"; +import { StyleSheet, useWindowDimensions, View } from "react-native"; +import type { SkiaView } from "@shopify/react-native-skia"; -type Point = { x: number; y: number }; +import { DrawingCanvas } from "./DrawingCanvas"; +import { SizeConstants } from "./constants"; +import { ColorPicker, DrawingToolMenu, MainToolbar, SizeMenu } from "./Toolbar"; +import { ToolbarItemSize } from "./Toolbar/Items/styles"; +import { useUxProvider, useDrawProvider, useShareNavButton } from "./Hooks"; export const DrawingExample: React.FC = () => { - const paint = usePaint((p) => p.setColor(Skia.Color("#7FC8A9"))); - const prevPointRef = useRef(); - - const pathPaint = usePaint((p) => { - p.setColor(Skia.Color("#7F33A9")); - p.setStrokeWidth(5); - p.setStyle(PaintStyle.Stroke); - p.setStrokeCap(StrokeCap.Round); - }); - - const paths = useMemo(() => [] as IPath[], []); - - const touchHandler = useTouchHandler({ - onStart: ({ x, y }) => { - const path = Skia.Path.Make(); - paths.push(path); - path.moveTo(x, y); - prevPointRef.current = { x, y }; - }, - onActive: ({ x, y }) => { - // Get current path object - const path = paths[paths.length - 1]; - - // Calculate and draw a smooth curve - const xMid = (prevPointRef.current!.x + x) / 2; - const yMid = (prevPointRef.current!.y + y) / 2; - - path.quadTo(prevPointRef.current!.x, prevPointRef.current!.y, xMid, yMid); - - prevPointRef.current = { x, y }; - }, - }); - - const onDraw = useDrawCallback( - (canvas, info) => { - // Update from pending touches - touchHandler(info.touches); + const skiaViewRef = useRef(null); - // Clear screen - canvas.drawPaint(paint); + useShareNavButton(skiaViewRef); - // Draw paths - if (paths.length > 0) { - for (let i = 0; i < paths.length; i++) { - canvas.drawPath(paths[i], pathPaint); - } - } - }, - [paint, pathPaint, paths] - ); + const UxProvider = useUxProvider(); + const DrawProvider = useDrawProvider(); - const skiaViewRef = useRef(null); + const { width, height } = useWindowDimensions(); + const styles = useMemo(() => createStyle(width, height), [width, height]); return ( - <> - - -