diff --git a/.cursor/commands/upgrade-dependencies.md b/.cursor/commands/upgrade-dependencies.md new file mode 100644 index 0000000..cfaaf8c --- /dev/null +++ b/.cursor/commands/upgrade-dependencies.md @@ -0,0 +1,12 @@ +--- +description: >- + Upgrade Yarn deps (root + expo-example) aggressively to latest compatible + versions (majors OK), fix peers and native stack, verify build/lint, sync + README and docs +--- + +# upgrade-dependencies + +Run **`.cursor/skills/upgrade-dependencies/SKILL.md`**. + +**Strategy:** prefer **latest** versions **including majors** (e.g. `2.3.4` → `3.x`), not only minor bumps—then fix **peer/native conflicts** (Reanimated + worklets, Expo SDK matrix, Skia, `bob` types paths). Use **`yarn upgrade-interactive --latest`** and **`npx expo install --fix`** after Expo SDK changes. Finish with **`yarn build`**, ESLint, **`tsc`**, **`expo-doctor`**, and updates to **README.md** (install peers) and **docs/PROJECT_OVERVIEW.md** (versions + scripts). diff --git a/.cursor/rules/react-native-free-canvas.mdc b/.cursor/rules/react-native-free-canvas.mdc new file mode 100644 index 0000000..33d6b25 --- /dev/null +++ b/.cursor/rules/react-native-free-canvas.mdc @@ -0,0 +1,30 @@ +--- +description: Conventions for react-native-free-canvas library (Skia, Reanimated, gestures) +globs: src/**/*.{ts,tsx} +alwaysApply: false +--- + +# react-native-free-canvas + +## Stack + +- Skia (`@shopify/react-native-skia`) for `Canvas` / `Path` / snapshots. +- Reanimated shared values and worklets; `react-native-gesture-handler` for pan/pinch. +- Use `scheduleOnRN` from `react-native-worklets` when calling React state setters from gesture/worklet code or animated reactions (not `runOnJS`, which is deprecated). See `drawing-canvas.tsx` and `index.tsx`. + +## Style (match repo) + +- Prettier: single quotes, trailing commas, avoid arrow parens when single arg. +- Prefer `StyleSheet` from `styles.ts` over new inline style objects on hot paths (README calls this out for `flex: 1`). +- Components: `forwardRef` + `memo` for canvas pieces; co-locate prop types in `types.ts` for public API. +- Use `import type` for type-only imports. +- Mark UI-thread callbacks with `'worklet'` where required. + +## Architecture + +- Do not edit `lib/` — run `yarn build` after changing `src/`. +- Two layers: `DrawingCanvas` (live stroke + gestures) over `DrawnCanvas` (committed paths). Context in `canvas-context.ts`; path completion sync uses `promises-delivery`. + +## Types / API + +- Extend `FreeCanvasProps` / `FreeCanvasRef` in `types.ts` when adding props or ref methods; export types from `index.tsx` as today. diff --git a/.cursor/skills/commit/SKILL.md b/.cursor/skills/commit/SKILL.md new file mode 100644 index 0000000..0e2ff39 --- /dev/null +++ b/.cursor/skills/commit/SKILL.md @@ -0,0 +1,51 @@ +--- +name: commit +description: >- + Prepares Conventional Commit messages, verifies scope, and commits changes in + react-native-free-canvas. Use when the user asks to commit, save work, or + finalize a change set before push or PR. +--- + +# Commit (react-native-free-canvas) + +## When to apply + +- User asks to **commit**, **stage**, or **write a commit message**. +- Wrapping up a feature/fix before **push** or **PR**. + +## Workflow + +1. **Inspect** + - Run `git status -sb` and `git diff` (and `git diff --staged` if anything is already staged). + - Confirm only intentional paths are included (no secrets, `.env`, local `.pack/` tarballs, or accidental `node_modules` edits). + +2. **Verify (when library source or build output changed)** + - If `src/` changed: from repo root run `yarn build`, `yarn eslint src --max-warnings 0`, and `npx tsc --noEmit -p tsconfig.json`. + - If only docs, `.npmignore`, `.cursor/`, or metadata changed, skip the full build unless the user asks. + +3. **Stage** + - `git add` only the paths that belong in this commit. Prefer **one logical commit** per request; split if the diff mixes unrelated concerns. + +4. **Message format (Conventional Commits)** + + ``` + type(scope): imperative subject under ~72 characters + + Optional body: what changed and why; breaking changes noted here. + ``` + + **Types** (common in this repo): `feat`, `fix`, `chore`, `docs`, `refactor`, `test`, `build`, `ci`, `revert`. + + **Scopes** (examples): `publish`, `deps`, `expo-example`, `canvas`, `eslint`, `cursor`, `bob`. + + - Subject: **imperative**, no trailing period, no vague “update” without context. + - **Breaking changes**: add `!` after scope/type or a `BREAKING CHANGE:` footer. + +5. **Commit** + - `git commit -m "type(scope): subject"` or use `-m` twice for subject + body. + - Do not amend or force-push unless the user explicitly asks. + +## What not to do + +- Do not commit generated `lib/` unless it is the deliberate outcome of `yarn build` for a release (this repo normally commits `lib/` when `src/` changes; follow existing practice on the branch). +- Do not bundle unrelated refactors with a focused fix in one commit without user approval. diff --git a/.cursor/skills/run-expo-example/SKILL.md b/.cursor/skills/run-expo-example/SKILL.md new file mode 100644 index 0000000..f251e9a --- /dev/null +++ b/.cursor/skills/run-expo-example/SKILL.md @@ -0,0 +1,55 @@ +--- +name: run-expo-example +description: >- + Run the Expo example app that consumes react-native-free-canvas from ../src. + Use when manually testing the library UI, after dependency upgrades, or when + verifying the example still starts with Metro. +--- + +# Run Expo example (test the library) + +## How the example depends on the library + +`expo-example/package.json` uses **`"react-native-free-canvas": "../src"`** so Metro bundles **library source** from the repo. Run **`yarn build`** at the repo root when you need **`lib/`** artifacts (publish or type consumers); the example does not require the tarball for day-to-day UI work. + +**Caveat:** If Metro ever resolves duplicate native copies of Skia/Reanimated, prefer a **packed install** (`npm pack` + `file:../.pack/…tgz`) for a consumer-faithful test (see **upgrade-dependencies** skill). + +## Commands + +**One command from repo root (install + start):** + +```bash +yarn demo +``` + +**Platform shortcuts:** + +```bash +yarn demo:ios +yarn demo:android +``` + +**Manual (same as `yarn demo` without the single alias):** + +```bash +yarn --cwd expo-example install && yarn --cwd expo-example start +``` + +After **native** or **Babel** dependency changes: + +```bash +cd expo-example && npx expo start --clear +``` + +## Verify library build (optional) + +From repo root: + +```bash +yarn build +yarn eslint src --max-warnings 0 +``` + +## Outside this repo + +From the library root: `yarn build && npm pack`, then in any app: `yarn add file:/absolute/path/to/react-native-free-canvas-.tgz`. diff --git a/.cursor/skills/upgrade-dependencies/SKILL.md b/.cursor/skills/upgrade-dependencies/SKILL.md new file mode 100644 index 0000000..ec7fb65 --- /dev/null +++ b/.cursor/skills/upgrade-dependencies/SKILL.md @@ -0,0 +1,119 @@ +--- +name: upgrade-dependencies +description: >- + Upgrades Yarn dependencies for the library root and expo-example, resolves + version skew (Skia / Reanimated / Worklets / RN / Expo), verifies build and + lint, and updates README and docs/PROJECT_OVERVIEW.md. Use when bumping + packages, fixing peer conflicts, after Expo SDK upgrades, or when the user + asks to upgrade or align dependencies. +--- + +# Upgrade dependencies (react-native-free-canvas) + +## Upgrade strategy: **aggressive (latest first)** + +Default to **latest stable** versions, **including majors** (e.g. `2.3.4` → `3.1.0`, not `2.5.0`), unless something in the stack forbids it. + +- **Goal:** maximize freshness in one pass; avoid timid “minor-only” bumps when a newer major is compatible. +- **Constraint:** after each wave of bumps, the tree must have **no unresolved peer dependency errors** and **no broken native stack** (rules below). Fix conflicts by adjusting pins or upgrading the whole related group (e.g. Expo SDK + RN together), not by indefinitely staying on old majors “to be safe.” +- **Expo example caveat:** packages **managed by Expo** for a given SDK (RN, Skia, Reanimated, worklets, gesture-handler, etc.) must stay **compatible with that SDK** until you **upgrade the Expo SDK** (then go aggressive on the new SDK’s matrix). For everything else (navigation, dev tools, non-Expo-pinned libs), prefer **latest** like the root. +- **Library root:** devDependencies may sit **newer** than the example (e.g. newer RN / Skia for `bob` + types) as long as **`yarn build`**, **`eslint`**, and **`tsc`** pass and peer rules are satisfied. + +## Scope + +Two workspaces (run commands from **repo root** unless noted): + +| Workspace | Path | Role | +|-----------|------|------| +| Library | `.` | `devDependencies` pin what `bob build` and ESLint typecheck against | +| Example | `expo-example/` | Expo app; native deps must match **Expo SDK** until SDK is upgraded | + +Package manager: **Yarn 1** (`packageManager` in root `package.json`). + +## Workflow (agent checklist) + +1. **Inventory** + - Read root `package.json` (`devDependencies`, `peerDependencies`, `dependencies`, `scripts`). + - Read `expo-example/package.json` (`dependencies`, `devDependencies`). + - Note: library source imports **`react-native-worklets`** (`scheduleOnRN` in `src/drawing-canvas.tsx`); consumers need a compatible **worklets** version with **Reanimated**. + +2. **Choose upgrade target (aggressive)** + - Run **`yarn outdated`** (root and `expo-example/`) and **`npm view version`** / release notes when a major jump is risky. + - **Expo example:** either (a) bump **`expo`** to the **latest SDK** you want, then run **`npx expo install --fix`** so native modules align to that SDK in one shot, or (b) stay on the current SDK but still take **latest patch/minor** Expo allows for pinned modules. Do not leave the app on an old SDK only to avoid editing `package.json`—upgrade SDK when the goal is “latest everywhere” and conflicts are resolved. + - **Library root:** bump **direct** devDependencies to **latest** (majors OK): ESLint, TypeScript, Prettier, `@typescript-eslint/*`, Skia, Reanimated, Gesture Handler, RN, React, `react-native-builder-bob`, etc., subject only to **peer/native** rules below. + - After **`react-native-builder-bob`** majors, re-check **`package.json`** `exports` / `types` paths against actual **`lib/typescript/**`** layout from `yarn build`. + +3. **Conflict rules (no broken native stack)** + + These override “always latest”: if peers clash, **fix the set** (upgrade/downgrade together) until installs are clean. + + - **Reanimated** ↔ **react-native-worklets:** follow **`peerDependencies`** on the chosen Reanimated version (pair must match; never bump one alone). + - **@shopify/react-native-skia:** JS version must match native; in the example, follow **`expo install`** for SDK-validated pins unless you also change SDK/native setup. + - **react-native-gesture-handler:** compatible with RN + Reanimated per upstream. + - **Do not** change native deps in `expo-example` without **`yarn install`** there and **`npx expo start --clear`** when natives change. + +4. **Apply upgrades (prefer latest, including majors)** + + ```bash + # Root — see everything; then bump aggressively + yarn outdated + # Interactive: select latest (including red/major) where rules allow + yarn upgrade-interactive --latest + # Or direct latest for specific packages: + # yarn add -D some-package@latest other-package@latest + yarn install + + # Example — same idea; use expo install after expo version changes + cd expo-example && yarn outdated + yarn upgrade-interactive --latest # for non-Expo-pinned deps + # When changing Expo SDK or core RN/Reanimated/Skia/worklets: + npx expo install expo@latest # or expo@~.0 then: + npx expo install --fix + yarn install + cd .. + ``` + + Resolve **peer dependency errors** (not mere warnings): adjust **`peerDependencies`** on the library root and/or example pins until **`yarn install`** exits successfully. + +5. **Verify (must pass before committing)** + + ```bash + yarn install + yarn build + yarn eslint src --max-warnings 0 + npx tsc --noEmit -p tsconfig.json + + cd expo-example && yarn install && npx eslint app --max-warnings 0 + cd .. + ``` + + Run **`npx expo-doctor`** from `expo-example/` after Expo or native bumps. + +6. **Update documents** (whenever versions or install story change) + + | File | What to sync | + |------|----------------| + | `README.md` | **Install** code block: list every root **`peerDependency`** with correct lower bounds (include `react-native-worklets` if the library imports it). | + | `docs/PROJECT_OVERVIEW.md` | **Tech stack** table and **Version notes**: reflect pinned majors (RN, Expo SDK, Skia, Reanimated, worklets). **Scripts and workflows**: must match **root** `package.json` `scripts` (do not document scripts that do not exist). | + | `.cursor/skills/run-expo-example/SKILL.md` | If root scripts for packing/syncing the example change, update that skill’s command list. | + +7. **Regenerate `lib/`** after `src/` or build config changes + + ```bash + yarn build + ``` + + Do not hand-edit `lib/`. + +## Common fixes + +- **Duplicate native modules / wrong Reanimated instance**: example must not symlink monorepo root for the library in a way that pulls a second `node_modules` tree for Skia/Reanimated (see `run-expo-example` skill if using tarball workflow). +- **`scheduleOnRN` / worklets**: ensure `react-native-worklets` is a **dependency** (not only devDependency) of any app that imports it directly from JS (e.g. `expo-example` if it uses `scheduleOnRN`). +- **`bob` / TypeScript emit layout** changed on builder major: update root **`package.json`** `types` and **`exports`**.**`types`** to match **`lib/typescript/**`**; add **`exclude`** entries in **`tsconfig.json`** if stray roots (e.g. `eslint.config.js`, `scripts/`) get pulled into declaration emit. +- **Yarn resolutions**: only add `resolutions` in root or example as a last resort; prefer aligning direct dependencies. + +## What not to do + +- Do not bump only one of **Reanimated** / **worklets** without checking compatibility. +- Do not commit secrets or local `.pack/` tarballs if policy forbids it. +- Do not edit `lib/` manually. diff --git a/.github/workflows/verify-pack.yml b/.github/workflows/verify-pack.yml new file mode 100644 index 0000000..e2a3488 --- /dev/null +++ b/.github/workflows/verify-pack.yml @@ -0,0 +1,29 @@ +name: Verify pack and example + +on: + push: + branches: [main, master] + pull_request: + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: yarn + cache-dependency-path: | + yarn.lock + expo-example/yarn.lock + + - name: Install root dependencies + run: yarn install --frozen-lockfile + + - name: Sync packed library into expo-example + run: yarn example:sync + + - name: Typecheck example app + run: cd expo-example && npx tsc --noEmit diff --git a/.npmignore b/.npmignore index e962854..4242509 100644 --- a/.npmignore +++ b/.npmignore @@ -1,4 +1,45 @@ -expo-example +# Published tarball is driven by package.json "files" (lib, src). These rules +# exclude anything that must never ship to npm, and prune junk inside src/lib. + +# --- Example app & repo-only trees (not library consumers) --- +expo-example/ +docs/ +scripts/ +.cursor/ +.github/ + +# --- Dependencies & package managers --- node_modules/ -.vscode -.yarn +.yarn/ +.pnp.cjs +.pnp.loader.mjs + +# --- Local / CI / editor --- +.vscode/ +.idea/ +.expo/ +.pack/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.DS_Store +coverage/ +.nyc_output/ + +# --- Dev config at repo root (not needed in node_modules install) --- +eslint.config.js +tsconfig.json +.editorconfig +.gitattributes +yarn.lock +package-lock.json + +# --- Tests & snapshots (if added under src/ later) --- +**/__tests__/ +**/__mocks__/ +**/*.test.ts +**/*.test.tsx +**/*.spec.ts +**/*.spec.tsx +**/*.snap diff --git a/.pack/react-native-free-canvas-2.0.0.tgz b/.pack/react-native-free-canvas-2.0.0.tgz new file mode 100644 index 0000000..d92e536 Binary files /dev/null and b/.pack/react-native-free-canvas-2.0.0.tgz differ diff --git a/.pack/react-native-free-canvas.tgz b/.pack/react-native-free-canvas.tgz new file mode 100644 index 0000000..d92e536 Binary files /dev/null and b/.pack/react-native-free-canvas.tgz differ diff --git a/README.md b/README.md index 0f3dcb8..eb2e312 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,23 @@ Freehand sketch on canvas based on [@shopify/react-native-skia](https://github. +## Demo app (Expo) + +From the **repository root** (after `yarn install` here once, if you have not already): + +```bash +yarn demo +``` + +That installs `expo-example` dependencies and starts Expo. Then press **`i`** (iOS), **`a`** (Android), or scan the QR code with Expo Go. + +Shortcuts: + +```bash +yarn demo:ios +yarn demo:android +``` + ## Install You need to install following dependencies ``` @@ -10,10 +27,14 @@ You need to install following dependencies "react": ">=18.0.0", "react-native": ">=0.72.0", "react-native-gesture-handler": ">=2.0.0", -"react-native-reanimated": ">=4.0.0", -"react-native-worklets": "*", +"react-native-reanimated": ">=4.1.0", +"react-native-worklets": ">=0.5.0" ``` +`react-native-worklets` is required at runtime (the library uses `scheduleOnRN` from gesture/worklet code). Match the **worklets** version range your **Reanimated** package declares (e.g. Reanimated **4.3.x** expects **worklets 0.8.x**; **Expo SDK 55** / **Reanimated ~4.2** uses **worklets 0.7.x** — always align with `peerDependencies` on the versions you install). + +Dependency upgrades in this repo: follow **`.cursor/skills/upgrade-dependencies/SKILL.md`** (or run the **upgrade-dependencies** command). + ## Usage ```ts import FreeCanvas from 'react-native-free-canvas'; diff --git a/docs/PROJECT_OVERVIEW.md b/docs/PROJECT_OVERVIEW.md new file mode 100644 index 0000000..557c677 --- /dev/null +++ b/docs/PROJECT_OVERVIEW.md @@ -0,0 +1,82 @@ +# react-native-free-canvas — project overview + +## Summary + +**react-native-free-canvas** is a React Native library (v2) for freehand drawing on a Skia canvas. It layers two Skia canvases (live stroke vs committed paths), uses Reanimated shared values for zoom/pan, and exposes an imperative ref API (undo, reset, snapshots, paths). Published output is built with **react-native-builder-bob** into `lib/` (CommonJS, ES modules, and TypeScript declarations). + +## Tech stack + +| Area | Choice | +|------|--------| +| UI / canvas | [@shopify/react-native-skia](https://github.com/Shopify/react-native-skia) (`Canvas`, `Path`, `Rect`, snapshots) | +| Gestures | [react-native-gesture-handler](https://docs.swmansion.com/react-native-gesture-handler/) (`Gesture.Pan`, `Gesture.Pinch`, `GestureDetector`) | +| Animation / UI thread | [react-native-reanimated](https://docs.swmansion.com/react-native-reanimated/) (`useSharedValue`, `useDerivedValue`, `useAnimatedStyle`, `useAnimatedReaction`, `withTiming`) | +| RN ↔ UI scheduling | [react-native-worklets](https://github.com/software-mansion/react-native-worklets) (`scheduleOnRN`) | +| Async path completion | [promises-delivery](https://www.npmjs.com/package/promises-delivery) (coordinate “path drawn” with layer commit) | +| Language | TypeScript (extends `@react-native/typescript-config`) | +| Lint / format | ESLint 9 flat config + Prettier (via `eslint-plugin-prettier`) | +| Build | `react-native-builder-bob` → `bob build` | +| Package manager | Yarn 1 (`packageManager` field) | + +**Peer dependencies** (consumers must install): Skia ≥2, React ≥18, RN ≥0.72, gesture-handler ≥2, **Reanimated ≥4.1**, **worklets ≥0.5** — match **worklets** to the **Reanimated** version you use (`peerDependencies` on those packages: 4.2.x needs worklets ≥0.7, 4.3.x needs worklets 0.8.x; see root `package.json`). + +**Example app** (`expo-example/`): Expo Router app; depends on **`react-native-free-canvas`** via **`file:../src`** (see `expo-example/package.json`). Use **`yarn --cwd expo-example install`** after changing library or example dependencies; clear Metro cache after native bumps (`npx expo start --clear`). + +## Repository layout + +| Path | Role | +|------|------| +| `src/` | Library source (the editable codebase) | +| `lib/` | Generated publish artifacts (do not hand-edit; regenerate with `yarn build`) | +| `expo-example/` | Demo app consuming **`react-native-free-canvas`** via **`file:../src`** (see `run-expo-example` skill) | +| `eslint.config.js` | ESLint flat config | +| `tsconfig.json` | TS config + path aliases `@root/*`, `@src/*` | +| `package.json` | `exports` map for ESM/CJS + types | + +## Architecture (high level) + +1. **`FreeCanvas`** (`src/index.tsx`): Root component wrapped in `GestureHandlerRootView`, `CanvasContext.Provider`, and `Animated.View` with transform order documented in the README (translate → scale, `transformOrigin`). +2. **`DrawingCanvas`**: Top absolute layer; captures pan/pinch; maintains a Skia path in a shared value; on finalize, registers completion via `promises-delivery` and pushes a `DrawnPath` into context. +3. **`DrawnCanvas`**: Bottom layer; renders background, committed `Path`s from React state, and resolves the delivery when the new path appears so the live path can clear. +4. **`CanvasContext`**: Holds drawn path list, drawing preview state, zoom/pan worklets (`setScale`, `setTranslate`, `finalize`), and `pathCompleteDelivery`. + +Performance-related choices: `memo` on exported components, `useMemo` / `useCallback` for stable gesture configs and context value where dependencies allow, README guidance to avoid inline `style={{ flex: 1 }}` objects to reduce re-renders. + +## Code style (enforced and conventional) + +### ESLint + Prettier (`eslint.config.js`) + +- **Prettier** (errors as ESLint): `singleQuote: true`, `trailingComma: 'all'`, `arrowParens: 'avoid'`, `endOfLine: 'auto'`. +- **Line length**: `max-len` 140 (strings/URLs ignored). +- **React**: JSX only in `.ts`/`.tsx`; `prop-types` and `display-name` off; flexible function component forms; `jsx-props-no-spreading` off. +- **TypeScript**: `@typescript-eslint/recommended`; `no-shadow` off in favor of `@typescript-eslint/no-shadow`; explicit return types not required. +- **Hooks**: `react-hooks/exhaustive-deps` off (typical for animation-heavy code where deps are intentional). +- **React Native**: `react-native/no-inline-styles` off (Skia `Canvas` still uses some inline layout where needed). + +### TypeScript patterns + +- **`import type`** for type-only imports from Skia, RN, Reanimated (see `types.ts`). +- **Component pattern**: `forwardRef` + `memo` for canvas components; default export of the main canvas. +- **Worklets**: `'worklet'` directive in callbacks that run on the UI thread; `getSharedValue` helper in `utils.ts` for `T | SharedValue`. + +### File naming + +- `*.tsx` for components with JSX; `*.ts` for context, types, utilities, styles. + +### Styling + +- Shared `StyleSheet.create` in `styles.ts` (`flex1`, `canvas` absolute fill, etc.). + +## Scripts and workflows + +- **Build library** (repo root): `yarn build` (runs `bob build`). +- **Example app**: `yarn --cwd expo-example install`, then `yarn --cwd expo-example start` (or `cd expo-example && npx expo start`). After native dependency changes, use `npx expo start --clear`. +- **Upgrading dependencies**: follow **`.cursor/skills/upgrade-dependencies/SKILL.md`** so root + `expo-example` stay aligned and README / this doc stay in sync. + +## Version notes + +The root `package.json` **devDependencies** pin the library dev stack for CI and `bob build` (currently **RN 0.85.x**, **Reanimated 4.3.x**, **worklets 0.8.x**, **Skia 2.6.x**, **gesture-handler 2.31.x** — see that file for exact pins). **`expo-example`** tracks **Expo SDK 55** (`expo` ~55.0.x, **RN 0.83.x**, **Skia 2.4.x** per `expo install`, **Reanimated ~4.2** / **worklets 0.7.x**). The example follows Expo’s pinned matrix; the root is often **one RN minor ahead** for typecheck and `bob` while keeping **Reanimated + worklets** internally consistent per their peers. Re-run **`yarn install`** in both roots after any bump; use **`npx expo start --clear`** after native changes. **Expo SDK 55** expects a current **Xcode** toolchain for local iOS prebuilds (see `npx expo-doctor` / [Expo–Xcode compatibility](https://expo.fyi/expo-sdk-xcode-compatibility)); Expo Go or EAS builds may still work on older hosts. + +## Further reading + +- Root `README.md`: API table, transform order, peer installs, `CornerPathEffect` example. diff --git a/expo-example/app.json b/expo-example/app.json index 8214a08..7d1a3ba 100644 --- a/expo-example/app.json +++ b/expo-example/app.json @@ -6,7 +6,6 @@ "orientation": "portrait", "scheme": "myapp", "userInterfaceStyle": "automatic", - "newArchEnabled": true, "ios": { "supportsTablet": true }, diff --git a/expo-example/app/_layout.tsx b/expo-example/app/_layout.tsx index 7e82ef1..b097191 100644 --- a/expo-example/app/_layout.tsx +++ b/expo-example/app/_layout.tsx @@ -1,14 +1,15 @@ -import { Stack } from 'expo-router'; - +import 'react-native-gesture-handler'; import 'react-native-reanimated'; -import React from 'react'; -// Prevent the splash screen from auto-hiding before asset loading is complete. +import { Slot } from 'expo-router'; +import React from 'react'; +/** + * Single-route demo: use Slot so we do not mount a native Stack parallel to + * expo-router's own navigation tree. Declaring @react-navigation/native ^7 at + * the app root can resolve ahead of expo-router's bundled version and triggers + * "Couldn't find the prevent remove context / NavigationContent" errors. + */ export default function RootLayout() { - return ( - - - - ); + return ; } diff --git a/expo-example/app/index.tsx b/expo-example/app/index.tsx index 44eb1fa..1b74c20 100644 --- a/expo-example/app/index.tsx +++ b/expo-example/app/index.tsx @@ -1,84 +1,634 @@ -import { StyleSheet, View, Button } from 'react-native'; -import React, { useRef, useState } from 'react'; -import FreeCanvas, { FreeCanvasRef } from 'react-native-free-canvas'; -import { Canvas, Image, Skia } from '@shopify/react-native-skia'; +import { + CornerPathEffect, + ImageFormat, + Path as SkiaPath, + Rect, + Canvas, + Image, + Skia, +} from '@shopify/react-native-skia'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { + Alert, + LayoutChangeEvent, + Pressable, + ScrollView, + Share, + StatusBar, + StyleSheet, + Switch, + Text, + View, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { scheduleOnRN } from 'react-native-worklets'; + +import FreeCanvas, { type FreeCanvasRef } from 'react-native-free-canvas'; + +const STROKE_PRESETS = [ + { color: '#38bdf8', label: 'Sky' }, + { color: '#f472b6', label: 'Pink' }, + { color: '#fbbf24', label: 'Amber' }, + { color: '#34d399', label: 'Mint' }, + { color: '#a78bfa', label: 'Violet' }, + { color: '#f8fafc', label: 'Snow' }, +] as const; + +const WIDTH_PRESETS = [4, 8, 14, 22] as const; + +const BG_PRESETS = ['#f1f5f9', '#ecfdf5', '#fff7ed', '#1e293b'] as const; + +const ZOOM_PRESETS: [number, number][] = [ + [0.35, 5], + [0.5, 2], + [0.8, 1.8], +]; + +function buildDotGridPath(width: number, height: number) { + const p = Skia.Path.Make(); + const step = 26; + for (let x = step; x < width; x += step) { + for (let y = step; y < height; y += step) { + p.addCircle(x, y, 1.25); + } + } + return p; +} + export default function HomeScreen() { const ref = useRef(null); - const [img, setImg] = useState(''); - const data = Skia.Data.fromBase64(img); - const image = Skia.Image.MakeImageFromEncoded(data); + const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 }); + const [strokeColor, setStrokeColor] = useState( + STROKE_PRESETS[0].color, + ); + const [strokeWidth, setStrokeWidth] = useState(8); + const [backgroundColor, setBackgroundColor] = useState(BG_PRESETS[0]); + const [smoothCorners, setSmoothCorners] = useState(true); + const [zoomable, setZoomable] = useState(true); + const [zoomRange, setZoomRange] = useState<[number, number]>(ZOOM_PRESETS[0]); + const [strokeCount, setStrokeCount] = useState(0); + const [snapshotB64, setSnapshotB64] = useState(null); + const [metrics, setMetrics] = useState({ + tx: 0, + ty: 0, + scale: 1, + ox: 0, + oy: 0, + }); + + const mergeMetrics = useCallback((patch: Partial) => { + setMetrics(m => ({ ...m, ...patch })); + }, []); + + const onTranslate = useCallback( + (x: number, y: number) => { + 'worklet'; + scheduleOnRN(mergeMetrics, { tx: x, ty: y }); + }, + [mergeMetrics], + ); + + const onScale = useCallback( + (scale: number) => { + 'worklet'; + scheduleOnRN(mergeMetrics, { scale }); + }, + [mergeMetrics], + ); + + const onTransformOriginChange = useCallback( + (x: number, y: number) => { + 'worklet'; + scheduleOnRN(mergeMetrics, { ox: x, oy: y }); + }, + [mergeMetrics], + ); + + const pathEffect = useMemo( + () => + smoothCorners ? ( + + ) : undefined, + [smoothCorners, strokeWidth], + ); + + const gridPath = useMemo(() => { + if (canvasSize.w < 8 || canvasSize.h < 8) { + return null; + } + return buildDotGridPath(canvasSize.w, canvasSize.h); + }, [canvasSize.w, canvasSize.h]); + + const foreground = useMemo(() => { + const w = Math.max(canvasSize.w, 1); + const h = Math.max(canvasSize.h, 1); + const t = 3; + const c = 'rgba(56, 189, 248, 0.45)'; + return ( + <> + + + + + + ); + }, [canvasSize.w, canvasSize.h]); + + const onCanvasLayout = useCallback((e: LayoutChangeEvent) => { + const { width, height } = e.nativeEvent.layout; + setCanvasSize({ w: width, h: height }); + }, []); + + const snapshotImage = useMemo(() => { + if (!snapshotB64) { + return null; + } + const data = Skia.Data.fromBase64(snapshotB64); + return Skia.Image.MakeImageFromEncoded(data); + }, [snapshotB64]); + + const capturePng = useCallback(async () => { + const b64 = await ref.current?.toBase64(ImageFormat.PNG, 92); + if (b64) { + setSnapshotB64(b64); + } else { + Alert.alert('Snapshot', 'Nothing to export yet — draw a stroke first.'); + } + }, []); + + const captureJpeg = useCallback(async () => { + const b64 = await ref.current?.toBase64(ImageFormat.JPEG, 88); + if (b64) { + setSnapshotB64(b64); + } else { + Alert.alert('Snapshot', 'Nothing to export yet — draw a stroke first.'); + } + }, []); + + const captureViaGetSnapshot = useCallback(async () => { + const img = await ref.current?.getSnapshot(); + if (!img) { + Alert.alert('getSnapshot', 'No image returned.'); + return; + } + const b64 = img.encodeToBase64(ImageFormat.PNG, 90); + setSnapshotB64(b64); + }, []); + + const sharePaths = useCallback(() => { + const paths = ref.current?.toPaths() ?? []; + const json = JSON.stringify(paths, null, 2); + Share.share({ message: json, title: 'Drawn paths' }).catch(() => {}); + }, []); + + const restoreDemo = useCallback(() => { + const paths = ref.current?.toPaths() ?? []; + if (paths.length === 0) { + Alert.alert('Paths', 'Draw something first, then use Restore demo.'); + return; + } + ref.current?.reset(); + requestAnimationFrame(() => { + ref.current?.drawPaths(paths); + }); + }, []); + + const handleUndo = useCallback(() => { + const steps = ref.current?.undo() as number | false | undefined; + if (typeof steps === 'number' && steps > 0) { + setStrokeCount(c => Math.max(0, c - steps)); + } + }, []); + return ( - - - - -