├── .github └── workflows │ ├── bun-test.yml │ └── deploy-demo.yml ├── .gitignore ├── LICENSE ├── README.md ├── bun.lockb ├── bunfig.toml ├── happydom.ts ├── index.ts ├── package.json ├── packages ├── npr-demo │ ├── .eslintrc.cjs │ ├── .gitignore │ ├── README.md │ ├── index.html │ ├── package.json │ ├── public │ │ ├── .nojekyll │ │ └── cobalt_icon.png │ ├── src │ │ ├── App.css │ │ ├── App.tsx │ │ ├── ExampleBlocks.css │ │ ├── ExampleBlocks.tsx │ │ ├── assets │ │ │ ├── NinePatchExample.svg │ │ │ ├── bubblegum.png │ │ │ ├── bubblegum_pixel.png │ │ │ ├── cobalt.png │ │ │ ├── copper.png │ │ │ ├── gold_and_blue.png │ │ │ └── grail.png │ │ ├── main.tsx │ │ └── vite-env.d.ts │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts └── npr │ ├── .gitignore │ ├── README.md │ ├── bunfig.toml │ ├── eslint.config.mjs │ ├── happydom.ts │ ├── index.ts │ ├── package.json │ ├── src │ ├── calc.test.ts │ ├── calc.ts │ ├── hooks.test.tsx │ ├── hooks.tsx │ ├── npr.test.tsx │ ├── npr.tsx │ ├── test_data │ │ ├── index.ts │ │ └── testframe_20x10.png │ └── types.ts │ └── tsconfig.json └── tsconfig.json /.github/workflows/bun-test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Bun CI 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Use Bun latest version 20 | uses: oven-sh/setup-bun@v2 21 | - run: bun install 22 | - run: cd packages/npr && bun test 23 | -------------------------------------------------------------------------------- /.github/workflows/deploy-demo.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: Deploy static content to Pages 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ["main"] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 19 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 20 | concurrency: 21 | group: "pages" 22 | cancel-in-progress: false 23 | 24 | jobs: 25 | # Build the page using Vite 26 | build-and-deploy: 27 | name: Build and deploy demo page 28 | environment: 29 | name: github-pages 30 | url: ${{ steps.deployment.outputs.page_url }} 31 | runs-on: ubuntu-latest 32 | steps: 33 | - name: Checkout 34 | uses: actions/checkout@v4 35 | - name: Setup Pages 36 | uses: actions/configure-pages@v5 37 | - name: Install Bun 38 | uses: oven-sh/setup-bun@v2 39 | - name: Install dependencies and build 40 | run: | 41 | bun install 42 | cd packages/npr-demo 43 | bun run build 44 | - name: Save artifact 45 | uses: actions/upload-pages-artifact@v3 46 | with: 47 | path: 48 | packages/npr-demo/dist 49 | - name: Deploy to GitHub Pages 50 | id: deployment 51 | uses: actions/deploy-pages@v4 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # Logs 4 | 5 | logs 6 | _.log 7 | npm-debug.log_ 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Caches 14 | 15 | .cache 16 | 17 | # Diagnostic reports (https://nodejs.org/api/report.html) 18 | 19 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 20 | 21 | # Runtime data 22 | 23 | pids 24 | _.pid 25 | _.seed 26 | *.pid.lock 27 | 28 | # Directory for instrumented libs generated by jscoverage/JSCover 29 | 30 | lib-cov 31 | 32 | # Coverage directory used by tools like istanbul 33 | 34 | coverage 35 | *.lcov 36 | 37 | # nyc test coverage 38 | 39 | .nyc_output 40 | 41 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 42 | 43 | .grunt 44 | 45 | # Bower dependency directory (https://bower.io/) 46 | 47 | bower_components 48 | 49 | # node-waf configuration 50 | 51 | .lock-wscript 52 | 53 | # Compiled binary addons (https://nodejs.org/api/addons.html) 54 | 55 | build/Release 56 | 57 | # Dependency directories 58 | 59 | node_modules/ 60 | jspm_packages/ 61 | 62 | # Snowpack dependency directory (https://snowpack.dev/) 63 | 64 | web_modules/ 65 | 66 | # TypeScript cache 67 | 68 | *.tsbuildinfo 69 | 70 | # Optional npm cache directory 71 | 72 | .npm 73 | 74 | # Optional eslint cache 75 | 76 | .eslintcache 77 | 78 | # Optional stylelint cache 79 | 80 | .stylelintcache 81 | 82 | # Microbundle cache 83 | 84 | .rpt2_cache/ 85 | .rts2_cache_cjs/ 86 | .rts2_cache_es/ 87 | .rts2_cache_umd/ 88 | 89 | # Optional REPL history 90 | 91 | .node_repl_history 92 | 93 | # Output of 'npm pack' 94 | 95 | *.tgz 96 | 97 | # Yarn Integrity file 98 | 99 | .yarn-integrity 100 | 101 | # dotenv environment variable files 102 | 103 | .env 104 | .env.development.local 105 | .env.test.local 106 | .env.production.local 107 | .env.local 108 | 109 | # parcel-bundler cache (https://parceljs.org/) 110 | 111 | .parcel-cache 112 | 113 | # Next.js build output 114 | 115 | .next 116 | out 117 | 118 | # Nuxt.js build / generate output 119 | 120 | .nuxt 121 | dist 122 | 123 | # Gatsby files 124 | 125 | # Comment in the public line in if your project uses Gatsby and not Next.js 126 | 127 | # https://nextjs.org/blog/next-9-1#public-directory-support 128 | 129 | # public 130 | 131 | # vuepress build output 132 | 133 | .vuepress/dist 134 | 135 | # vuepress v2.x temp and cache directory 136 | 137 | .temp 138 | 139 | # Docusaurus cache and generated files 140 | 141 | .docusaurus 142 | 143 | # Serverless directories 144 | 145 | .serverless/ 146 | 147 | # FuseBox cache 148 | 149 | .fusebox/ 150 | 151 | # DynamoDB Local files 152 | 153 | .dynamodb/ 154 | 155 | # TernJS port file 156 | 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | 161 | .vscode-test 162 | 163 | # yarn v2 164 | 165 | .yarn/cache 166 | .yarn/unplugged 167 | .yarn/build-state.yml 168 | .yarn/install-state.gz 169 | .pnp.* 170 | 171 | # IntelliJ based IDEs 172 | .idea 173 | 174 | # Finder (MacOS) folder config 175 | .DS_Store 176 | 177 | # Builds 178 | packages/*/dist -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 Simone Sturniolo 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), 4 | to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, 5 | and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 10 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 11 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 12 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nine Patch React 2 | 3 | A React component for creating frames with the [nine patch technique](https://en.wikipedia.org/wiki/9-slice_scaling). 4 | 5 | ![The nine patch technique](./packages/npr-demo/src/assets/NinePatchExample.svg) 6 | 7 | ## How to install 8 | 9 | Just use 10 | 11 | ```bash 12 | npm install --save @stur86/nine-patch-react 13 | ``` 14 | 15 | or your package manager's equivalent. 16 | 17 | ## How to use 18 | 19 | Import the component like this: 20 | 21 | ```tsx 22 | import NinePatch from "@stur86/nine-patch-react"; 23 | ``` 24 | 25 | And use like this: 26 | 27 | ```tsx 28 | 29 | Content goes here 30 | 31 | ``` 32 | 33 | More information in the [demo and documentation page](https://stur86.github.io/nine-patch-react/). 34 | 35 | ## How to develop 36 | 37 | This package was developed using [Bun](https://bun.sh/). The repository uses the workspaces function to host multiple packages, which are: 38 | 39 | * `npr`: the core library 40 | * `npr-demo`: the demo and documentation page, built using React and Vite 41 | 42 | If you want to propose any new features or fix any bugs, forks and pull requests are welcome! There is a suite of tests you can run with `bun test` inside the `packages/npr` folder, and you can launch the demo page in development mode with `bun run dev` in `packages/npr-demo`. 43 | 44 | ## Made with 45 | 46 | * **React**, obviously 47 | * **Bun** for package managing and testing 48 | * **Vite** for the bundling and building of the demo page 49 | * **Google Fonts** for the demo page itself 50 | * **react-code-block** for the highlighted code in the examples. This in turn uses **Prism** for the actual highlighting. In addition, **react-element-to-jsx-string** was an amazing help as it allowed me to turn the example component into its TSX source code without a need to duplicate it. Extremely convenient! 51 | * **Aseprite** and **Inkscape** for the frames used in the demos -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stur86/nine-patch-react/bfa3291763c6c51e352bc76c32467524b83d1422/bun.lockb -------------------------------------------------------------------------------- /bunfig.toml: -------------------------------------------------------------------------------- 1 | [test] 2 | preload = "./happydom.ts" -------------------------------------------------------------------------------- /happydom.ts: -------------------------------------------------------------------------------- 1 | import { GlobalRegistrator } from "@happy-dom/global-registrator"; 2 | 3 | GlobalRegistrator.register({ 4 | width: 1024, 5 | height: 768 6 | }); -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | console.log("Hello via Bun!"); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "npr-root", 3 | "module": "index.ts", 4 | "type": "module", 5 | "workspaces": [ 6 | "packages/*" 7 | ], 8 | "devDependencies": { 9 | "@types/bun": "latest" 10 | }, 11 | "peerDependencies": { 12 | "typescript": "^5.5.4" 13 | }, 14 | "scripts": { 15 | "publish-page": "cd packages/npr-demo && bun run build && bun run gh-pages -d ./dist" 16 | } 17 | } -------------------------------------------------------------------------------- /packages/npr-demo/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /packages/npr-demo/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /packages/npr-demo/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | export default { 18 | // other rules... 19 | parserOptions: { 20 | ecmaVersion: 'latest', 21 | sourceType: 'module', 22 | project: ['./tsconfig.json', './tsconfig.node.json'], 23 | tsconfigRootDir: __dirname, 24 | }, 25 | } 26 | ``` 27 | 28 | - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` 29 | - Optionally add `plugin:@typescript-eslint/stylistic-type-checked` 30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list 31 | -------------------------------------------------------------------------------- /packages/npr-demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Nine Patch React 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /packages/npr-demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "npr-demo", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@fortawesome/fontawesome-free": "^6.6.0", 14 | "@types/prismjs": "^1.26.4", 15 | "@stur86/nine-patch-react": "workspace:*", 16 | "prism-react-renderer": "^2.4.0", 17 | "prismjs": "^1.29.0", 18 | "react": "^18.2.0", 19 | "react-code-block": "^1.0.0", 20 | "react-dom": "^18.2.0", 21 | "react-element-to-jsx-string": "^15.0.0" 22 | }, 23 | "devDependencies": { 24 | "@types/react": "^18.2.66", 25 | "@types/react-dom": "^18.2.22", 26 | "@typescript-eslint/eslint-plugin": "^7.2.0", 27 | "@typescript-eslint/parser": "^7.2.0", 28 | "@vitejs/plugin-react": "^4.2.1", 29 | "eslint": "^8.57.0", 30 | "eslint-plugin-react-hooks": "^4.6.0", 31 | "eslint-plugin-react-refresh": "^0.4.6", 32 | "prettier": "^3.3.3", 33 | "typescript": "^5.2.2", 34 | "vite": "^5.2.0" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /packages/npr-demo/public/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stur86/nine-patch-react/bfa3291763c6c51e352bc76c32467524b83d1422/packages/npr-demo/public/.nojekyll -------------------------------------------------------------------------------- /packages/npr-demo/public/cobalt_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stur86/nine-patch-react/bfa3291763c6c51e352bc76c32467524b83d1422/packages/npr-demo/public/cobalt_icon.png -------------------------------------------------------------------------------- /packages/npr-demo/src/App.css: -------------------------------------------------------------------------------- 1 | @import "https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css"; 2 | @import url("https://fonts.googleapis.com/css2?family=Jersey+25&display=swap"); 3 | @import url('https://fonts.googleapis.com/css2?family=Pacifico&display=swap'); 4 | 5 | #hero-title { 6 | font-size: 4em; 7 | color: antiquewhite; 8 | font-family: "Jersey 25", sans-serif; 9 | font-weight: 400; 10 | font-style: normal; 11 | text-align: center; 12 | } 13 | 14 | /** A fix for code blocks **/ 15 | 16 | pre .tag { 17 | font-size: 1em; 18 | padding-left: 0; 19 | padding-right: 0; 20 | display: inline; 21 | background-color: transparent; 22 | border-radius: 0; 23 | line-height: normal; 24 | white-space: pre-wrap; 25 | } 26 | 27 | .pixel-text { 28 | font-size: 1.5em; 29 | font-family: "Jersey 25", sans-serif; 30 | font-weight: 400; 31 | font-style: normal; 32 | text-align: center; 33 | } 34 | 35 | .cute-text { 36 | font-size: 2em; 37 | font-family: "Pacifico", cursive; 38 | font-weight: 400; 39 | font-style: normal; 40 | text-align: center; 41 | color: #ff336b; 42 | } 43 | 44 | #properties-table td, #properties-table th { 45 | padding: 0.5em 2em; 46 | } 47 | 48 | .h-100 { 49 | height: 100%; 50 | } -------------------------------------------------------------------------------- /packages/npr-demo/src/App.tsx: -------------------------------------------------------------------------------- 1 | import "./App.css"; 2 | import NinePatch from "@stur86/nine-patch-react"; 3 | import cobaltUrl from "./assets/cobalt.png"; 4 | import copperUrl from "./assets/copper.png"; 5 | import grailUrl from "./assets/grail.png"; 6 | import goldBlueUrl from "./assets/gold_and_blue.png"; 7 | import bubblegumUrl from "./assets/bubblegum.png"; 8 | import "@fortawesome/fontawesome-free/css/all.min.css"; 9 | import exampleStrip from "./assets/NinePatchExample.svg"; 10 | import { TSXBlock, BashBlock, TSXCompareBlock } from "./ExampleBlocks"; 11 | 12 | const packageName = "@stur86/nine-patch-react"; 13 | 14 | const installCodes = { 15 | npm: `npm install --save ${packageName}`, 16 | bun: `bun add ${packageName}`, 17 | yarn: `yarn add ${packageName}` 18 | }; 19 | 20 | const basicExampleCode = ` 21 | import NinePatch from "${packageName}"; 22 | 23 | ... 24 | 25 | 26 | Content here! 27 | `; 28 | 29 | function ImgBlock({ src, alt }: { src: string; alt?: string }) { 30 | return ( 31 |
35 | {alt} 40 |
41 | ); 42 | } 43 | 44 | function App() { 45 | 46 | return ( 47 | <> 48 | 66 |
67 |
68 |
69 | 76 |
77 | Nine Patch React 78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |

86 | Nine Patch React provides a simple React component,{" "} 87 | <NinePatch>, that you can use to put any content 88 | inside a box rendered from an image using the "nine-patch" technique. 89 | This technique allows you to create a scalable box with unique borders 90 | and corners from a base image. 91 |

92 |

Contents

93 |
    94 |
  1. How it works
  2. 95 |
  3. Installation and usage
  4. 96 |
  5. Showcase
  6. 97 |
  7. Properties
  8. 98 |
99 |
100 |
101 |

How it works

102 | Nine Patch React provides a simple React component,{" "} 103 | <NinePatch>, that you can use to put any content 104 | inside a box rendered from an image using the "nine patch" or "nine 105 | slice" technique. This technique allows you to create a scalable box 106 | with unique borders and corners from a base image. 107 | 108 | Read more on the technique on 109 | 110 | Wikipedia: 9-slice scaling 111 | 112 | . 113 |
114 |
115 |

Installation and usage

116 | To install simply run the following command in your project directory: 117 | 118 | And you can use it simply like this: 119 | 120 |
121 |
122 |

Showcase

123 | Here is an example of a simple pixel art frame: 124 | 125 | You can use it to create a text box, like this: 126 | 127 | 128 | Hello, world! 129 | 130 | 131 | The text box size is controlled by its contents and the container it's in. For example: 132 | 133 |
134 | 135 | Hello, world! 136 | 137 |
138 |
139 | The border variables control how much of the frame is considered to constitute the "border" of the frame. Since 140 | the content is only inserted in the central cell of the nine patches, the border will also behave as a sort of padding. 141 | In order for the frame to visualize correctly the important thing is that the border here includes the actual edge, which 142 | is only 4 pixels wide. This gives us some leeway. Normally the border is set to 33% of the image size. It can be expressed 143 | in pixels or as a percentage of the image size. We can make it smaller to get a tighter fit: 144 | 145 |
146 | 147 | Hello, world! 148 | 149 |
150 |
151 | We can also use the scale option to make the image bigger and get a proper "pixel art" look. It's important to use 152 | the pixelPerfect option to get the best result and avoid unwanted smoothing. Here's an example: 153 | 154 |
155 | 156 | Hello, world! 157 | 158 |
159 |
160 | 161 | And of course, you can also use this to work with regular art. 162 | In that case you won't need the pixelPerfect option. 163 | 164 | 165 | 166 | 167 | Hello, world! 168 | 169 | 170 | 171 | You can put more than just basic text inside a frame, of course. Here's an example with an image: 172 | 173 | 174 |
175 | 176 |
177 | The Holy Grail 178 |
179 |
180 |
181 |
182 | 183 | Or some code 184 | 185 | 186 | 187 |
188 |                 console.log("Hello, world!");
189 |               
190 |
191 |
192 |
193 |
194 |

Properties

195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 |
PropertyTypeDescription
srcstringPath to the image to use as a frame.
scalenumber (Optional)Scale factor for the image. Default is 1.
pixelPerfectboolean (Optional)Whether to use pixel-perfect scaling. Default is false.
borderTopstring (Optional)Size of the top border. Can be in pixels or percentage. Default is 33%.
borderRightstring (Optional)Size of the right border. Can be in pixels or percentage. Default is 33%.
borderBottomstring (Optional)Size of the bottom border. Can be in pixels or percentage. Default is 33%.
borderLeftstring (Optional)Size of the left border. Can be in pixels or percentage. Default is 33%.
241 |
242 |
243 | 254 | 255 | ); 256 | } 257 | 258 | export default App; 259 | -------------------------------------------------------------------------------- /packages/npr-demo/src/ExampleBlocks.css: -------------------------------------------------------------------------------- 1 | .tsx-copy-icon { 2 | position: absolute; 3 | bottom: 5px; 4 | right: 5px; 5 | margin: 5px; 6 | font-size: 1.5em; 7 | background-color: transparent; 8 | padding: 20px; 9 | border-radius: 10px; 10 | cursor: pointer; 11 | } 12 | 13 | .tsx-copy-icon:hover { 14 | background-color: rgba(255, 255, 255, 0.05); 15 | } -------------------------------------------------------------------------------- /packages/npr-demo/src/ExampleBlocks.tsx: -------------------------------------------------------------------------------- 1 | import { CodeBlock } from "react-code-block"; 2 | import "./ExampleBlocks.css"; 3 | import { useState, PropsWithChildren } from "react"; 4 | import reactElementToJSXString from "react-element-to-jsx-string"; 5 | 6 | type TSXBlockProps = { 7 | title: string; 8 | code: string; 9 | }; 10 | 11 | function CopyIcon({ onClick }: { onClick: () => void }) { 12 | return ( 13 | 14 | 15 | 16 | ); 17 | } 18 | 19 | function copyToClipboard(text: string) { 20 | navigator.clipboard.writeText(text); 21 | } 22 | 23 | 24 | export function TSXBlock({ code, title }: TSXBlockProps) { 25 | 26 | 27 | return ( 28 |
29 |
30 |

{title}

31 |
32 | { copyToClipboard(code) }} /> 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 |
41 | ); 42 | } 43 | 44 | type TSXCompareBlockProps = { 45 | title: string; 46 | }; 47 | 48 | export function TSXCompareBlock({ title, children }: PropsWithChildren) { 49 | const code = reactElementToJSXString(children, { maxInlineAttributesLineLength: 100, showDefaultProps: false }); 50 | 51 | return ( 52 |
53 | {children} 54 | 55 |
56 | ) 57 | } 58 | 59 | type BashBlockProps = { 60 | allCodes: Record; 61 | }; 62 | 63 | export function BashBlock({ allCodes }: BashBlockProps) { 64 | const modes = Object.keys(allCodes); 65 | const [mode, setMode] = useState(modes[0]); 66 | const code = allCodes[mode]; 67 | 68 | return ( 69 |
70 |
71 | 78 |
79 | { copyToClipboard(code) }} /> 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 |
88 | ); 89 | } -------------------------------------------------------------------------------- /packages/npr-demo/src/assets/NinePatchExample.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 39 | 41 | 45 | 52 | 55 | 57 | 64 | 71 | 78 | 85 | 92 | 99 | 100 | Take an imageof a frame 116 | 117 | 120 | 127 | 134 | 141 | 148 | 155 | 162 | 166 | 170 | 175 | 180 | Stretch and rescale to include anything 191 | Anything! 202 | 203 | 206 | 209 | 216 | 223 | 230 | 237 | 244 | 251 | 252 | 259 | 266 | 273 | 280 | 287 | 294 | 302 | 310 | 318 | 321 | 323 | 327 | 331 | 332 | 335 | 339 | 343 | 344 | 345 | Define theborders 361 | borderLeft 372 | borderRight 383 | borderTop 394 | borderBottom 405 | 406 | 407 | 408 | -------------------------------------------------------------------------------- /packages/npr-demo/src/assets/bubblegum.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stur86/nine-patch-react/bfa3291763c6c51e352bc76c32467524b83d1422/packages/npr-demo/src/assets/bubblegum.png -------------------------------------------------------------------------------- /packages/npr-demo/src/assets/bubblegum_pixel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stur86/nine-patch-react/bfa3291763c6c51e352bc76c32467524b83d1422/packages/npr-demo/src/assets/bubblegum_pixel.png -------------------------------------------------------------------------------- /packages/npr-demo/src/assets/cobalt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stur86/nine-patch-react/bfa3291763c6c51e352bc76c32467524b83d1422/packages/npr-demo/src/assets/cobalt.png -------------------------------------------------------------------------------- /packages/npr-demo/src/assets/copper.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stur86/nine-patch-react/bfa3291763c6c51e352bc76c32467524b83d1422/packages/npr-demo/src/assets/copper.png -------------------------------------------------------------------------------- /packages/npr-demo/src/assets/gold_and_blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stur86/nine-patch-react/bfa3291763c6c51e352bc76c32467524b83d1422/packages/npr-demo/src/assets/gold_and_blue.png -------------------------------------------------------------------------------- /packages/npr-demo/src/assets/grail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stur86/nine-patch-react/bfa3291763c6c51e352bc76c32467524b83d1422/packages/npr-demo/src/assets/grail.png -------------------------------------------------------------------------------- /packages/npr-demo/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App.tsx"; 4 | 5 | ReactDOM.createRoot(document.getElementById("root")!).render( 6 | 7 | 8 | , 9 | ); 10 | -------------------------------------------------------------------------------- /packages/npr-demo/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /packages/npr-demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /packages/npr-demo/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true 9 | }, 10 | "include": ["vite.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /packages/npr-demo/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | base: './', 8 | esbuild: { 9 | minifyIdentifiers: false 10 | }, 11 | build: { 12 | minify: "esbuild" 13 | } 14 | }) 15 | -------------------------------------------------------------------------------- /packages/npr/.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # Logs 4 | 5 | logs 6 | _.log 7 | npm-debug.log_ 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Caches 14 | 15 | .cache 16 | 17 | # Diagnostic reports (https://nodejs.org/api/report.html) 18 | 19 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 20 | 21 | # Runtime data 22 | 23 | pids 24 | _.pid 25 | _.seed 26 | *.pid.lock 27 | 28 | # Directory for instrumented libs generated by jscoverage/JSCover 29 | 30 | lib-cov 31 | 32 | # Coverage directory used by tools like istanbul 33 | 34 | coverage 35 | *.lcov 36 | 37 | # nyc test coverage 38 | 39 | .nyc_output 40 | 41 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 42 | 43 | .grunt 44 | 45 | # Bower dependency directory (https://bower.io/) 46 | 47 | bower_components 48 | 49 | # node-waf configuration 50 | 51 | .lock-wscript 52 | 53 | # Compiled binary addons (https://nodejs.org/api/addons.html) 54 | 55 | build/Release 56 | 57 | # Dependency directories 58 | 59 | node_modules/ 60 | jspm_packages/ 61 | 62 | # Snowpack dependency directory (https://snowpack.dev/) 63 | 64 | web_modules/ 65 | 66 | # TypeScript cache 67 | 68 | *.tsbuildinfo 69 | 70 | # Optional npm cache directory 71 | 72 | .npm 73 | 74 | # Optional eslint cache 75 | 76 | .eslintcache 77 | 78 | # Optional stylelint cache 79 | 80 | .stylelintcache 81 | 82 | # Microbundle cache 83 | 84 | .rpt2_cache/ 85 | .rts2_cache_cjs/ 86 | .rts2_cache_es/ 87 | .rts2_cache_umd/ 88 | 89 | # Optional REPL history 90 | 91 | .node_repl_history 92 | 93 | # Output of 'npm pack' 94 | 95 | *.tgz 96 | 97 | # Yarn Integrity file 98 | 99 | .yarn-integrity 100 | 101 | # dotenv environment variable files 102 | 103 | .env 104 | .env.development.local 105 | .env.test.local 106 | .env.production.local 107 | .env.local 108 | 109 | # parcel-bundler cache (https://parceljs.org/) 110 | 111 | .parcel-cache 112 | 113 | # Next.js build output 114 | 115 | .next 116 | out 117 | 118 | # Nuxt.js build / generate output 119 | 120 | .nuxt 121 | dist 122 | 123 | # Gatsby files 124 | 125 | # Comment in the public line in if your project uses Gatsby and not Next.js 126 | 127 | # https://nextjs.org/blog/next-9-1#public-directory-support 128 | 129 | # public 130 | 131 | # vuepress build output 132 | 133 | .vuepress/dist 134 | 135 | # vuepress v2.x temp and cache directory 136 | 137 | .temp 138 | 139 | # Docusaurus cache and generated files 140 | 141 | .docusaurus 142 | 143 | # Serverless directories 144 | 145 | .serverless/ 146 | 147 | # FuseBox cache 148 | 149 | .fusebox/ 150 | 151 | # DynamoDB Local files 152 | 153 | .dynamodb/ 154 | 155 | # TernJS port file 156 | 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | 161 | .vscode-test 162 | 163 | # yarn v2 164 | 165 | .yarn/cache 166 | .yarn/unplugged 167 | .yarn/build-state.yml 168 | .yarn/install-state.gz 169 | .pnp.* 170 | 171 | # IntelliJ based IDEs 172 | .idea 173 | 174 | # Finder (MacOS) folder config 175 | .DS_Store 176 | -------------------------------------------------------------------------------- /packages/npr/README.md: -------------------------------------------------------------------------------- 1 | # nine-patch-react 2 | 3 | To install dependencies: 4 | 5 | ```bash 6 | bun install 7 | ``` 8 | 9 | To run: 10 | 11 | ```bash 12 | bun run index.ts 13 | ``` 14 | 15 | This project was created using `bun init` in bun v1.1.22. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. 16 | -------------------------------------------------------------------------------- /packages/npr/bunfig.toml: -------------------------------------------------------------------------------- 1 | [test] 2 | preload = "./happydom.ts" -------------------------------------------------------------------------------- /packages/npr/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import eslint from '@eslint/js'; 4 | import tseslint from 'typescript-eslint'; 5 | 6 | export default tseslint.config( 7 | eslint.configs.recommended, 8 | ...tseslint.configs.recommended, 9 | ); -------------------------------------------------------------------------------- /packages/npr/happydom.ts: -------------------------------------------------------------------------------- 1 | import { GlobalRegistrator } from "@happy-dom/global-registrator"; 2 | 3 | GlobalRegistrator.register({ 4 | width: 1024, 5 | height: 768 6 | }); -------------------------------------------------------------------------------- /packages/npr/index.ts: -------------------------------------------------------------------------------- 1 | import NinePatch from "./src/npr"; 2 | 3 | export default NinePatch; -------------------------------------------------------------------------------- /packages/npr/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@stur86/nine-patch-react", 3 | "module": "index.ts", 4 | "version": "1.0.0", 5 | "devDependencies": { 6 | "@eslint/js": "^9.11.1", 7 | "@happy-dom/global-registrator": "^15.7.3", 8 | "@testing-library/react": "^16.0.1", 9 | "@testing-library/react-hooks": "^8.0.1", 10 | "@types/bun": "latest", 11 | "@types/eslint__js": "^8.42.3", 12 | "@testing-library/dom": "^10.4.0", 13 | "typescript-eslint": "^8.7.0" 14 | }, 15 | "peerDependencies": { 16 | "typescript": "^5.0.0" 17 | }, 18 | "type": "module", 19 | "dependencies": { 20 | "react": "^18.2.0" 21 | }, 22 | "scripts": { 23 | "lint": "eslint . --fix" 24 | } 25 | } -------------------------------------------------------------------------------- /packages/npr/src/calc.test.ts: -------------------------------------------------------------------------------- 1 | import { calcReferencedSize, GridStyleCalculator, BorderCalculator } from "./calc"; 2 | import { describe, it, expect } from "bun:test"; 3 | 4 | 5 | describe("calcReferencedSize", () => { 6 | it("should calculate pixel size correctly", () => { 7 | expect(calcReferencedSize("10px", 100)).toBe(10); 8 | expect(calcReferencedSize("20px", 100)).toBe(20); 9 | }); 10 | 11 | it("should calculate percentage size correctly", () => { 12 | expect(calcReferencedSize("10%", 100)).toBe(10); 13 | expect(calcReferencedSize("20%", 500)).toBe(100); 14 | }); 15 | 16 | it("should throw an error for invalid size strings", () => { 17 | expect(() => calcReferencedSize("10", 100)).toThrow(); 18 | expect(() => calcReferencedSize("10em", 100)).toThrow(); 19 | }); 20 | }); 21 | 22 | describe("BorderCalculator", () => { 23 | it("should calculate border sizes correctly", () => { 24 | const border = new BorderCalculator({ 25 | left: "10%", 26 | right: "20%", 27 | top: "30%", 28 | bottom: "40%" 29 | }, {width: 100, height: 200}); 30 | 31 | expect(border.left).toBe(10); 32 | expect(border.right).toBe(20); 33 | expect(border.top).toBe(60); 34 | expect(border.bottom).toBe(80); 35 | expect(border.width).toBe(30); 36 | expect(border.height).toBe(140); 37 | }); 38 | 39 | it("should work with pixel sizes", () => { 40 | const border = new BorderCalculator({ 41 | left: "10px", 42 | right: "20px", 43 | top: "30px", 44 | bottom: "40px" 45 | }, {width: 100, height: 200}); 46 | 47 | expect(border.left).toBe(10); 48 | expect(border.right).toBe(20); 49 | expect(border.top).toBe(30); 50 | expect(border.bottom).toBe(40); 51 | expect(border.width).toBe(30); 52 | expect(border.height).toBe(70); 53 | }); 54 | }); 55 | 56 | describe("GridStyleCalculator", () => { 57 | it("should calculate grid and cell styles correctly", () => { 58 | const grid = new GridStyleCalculator( 59 | {width: 100, height: 200}, 60 | {width: 240, height: 320}, 61 | { 62 | left: "10%", 63 | right: "20%", 64 | top: "30%", 65 | bottom: "40%" 66 | }); 67 | 68 | expect(grid.gridStyle).toEqual({ 69 | display: "grid", 70 | gridTemplateColumns: "10px auto 20px", 71 | gridTemplateRows: "60px auto 80px" 72 | }); 73 | 74 | // Now for cell styles 75 | expect(grid.getCellStyle(0, 0)).toEqual({ 76 | backgroundRepeat: "no-repeat", 77 | gridRow: "1 / 2", 78 | gridColumn: "1 / 2", 79 | backgroundPosition: "left top", 80 | backgroundSize: "100px 200px" 81 | }); 82 | expect(grid.getCellStyle(1, 0)).toEqual({ 83 | backgroundRepeat: "no-repeat", 84 | gridRow: "2 / 3", 85 | gridColumn: "1 / 2", 86 | backgroundPosition: "left center", 87 | backgroundSize: "100px 600px" 88 | }); 89 | expect(grid.getCellStyle(2, 0)).toEqual({ 90 | backgroundRepeat: "no-repeat", 91 | gridRow: "3 / 4", 92 | gridColumn: "1 / 2", 93 | backgroundPosition: "left bottom", 94 | backgroundSize: "100px 200px" 95 | }); 96 | expect(grid.getCellStyle(0, 1)).toEqual({ 97 | backgroundRepeat: "no-repeat", 98 | gridRow: "1 / 2", 99 | gridColumn: "2 / 3", 100 | backgroundPosition: "center top", 101 | backgroundSize: "300px 200px" 102 | }); 103 | expect(grid.getCellStyle(1, 1)).toEqual({ 104 | backgroundRepeat: "no-repeat", 105 | gridRow: "2 / 3", 106 | gridColumn: "2 / 3", 107 | backgroundPosition: "center center", 108 | backgroundSize: "300px 600px" 109 | }); 110 | expect(grid.getCellStyle(2, 1)).toEqual({ 111 | backgroundRepeat: "no-repeat", 112 | gridRow: "3 / 4", 113 | gridColumn: "2 / 3", 114 | backgroundPosition: "center bottom", 115 | backgroundSize: "300px 200px" 116 | }); 117 | expect(grid.getCellStyle(0, 2)).toEqual({ 118 | backgroundRepeat: "no-repeat", 119 | gridRow: "1 / 2", 120 | gridColumn: "3 / 4", 121 | backgroundPosition: "right top", 122 | backgroundSize: "100px 200px" 123 | }); 124 | expect(grid.getCellStyle(1, 2)).toEqual({ 125 | backgroundRepeat: "no-repeat", 126 | gridRow: "2 / 3", 127 | gridColumn: "3 / 4", 128 | backgroundPosition: "right center", 129 | backgroundSize: "100px 600px" 130 | }); 131 | expect(grid.getCellStyle(2, 2)).toEqual({ 132 | backgroundRepeat: "no-repeat", 133 | gridRow: "3 / 4", 134 | gridColumn: "3 / 4", 135 | backgroundPosition: "right bottom", 136 | backgroundSize: "100px 200px" 137 | }); 138 | }); 139 | 140 | it("should deal with scaling", () => { 141 | const grid = new GridStyleCalculator( 142 | {width: 100, height: 200}, 143 | {width: 255, height: 330}, 144 | { 145 | left: "10%", 146 | right: "20%", 147 | top: "30%", 148 | bottom: "40%" 149 | }, 1.5); 150 | 151 | expect(grid.gridStyle).toEqual({ 152 | display: "grid", 153 | gridTemplateColumns: "15px auto 30px", 154 | gridTemplateRows: "90px auto 120px" 155 | }); 156 | 157 | // Grab a corner cell 158 | expect(grid.getCellStyle(0, 0)).toEqual({ 159 | backgroundRepeat: "no-repeat", 160 | gridRow: "1 / 2", 161 | gridColumn: "1 / 2", 162 | backgroundPosition: "left top", 163 | backgroundSize: "150px 300px" 164 | }); 165 | // Grab a side cell 166 | expect(grid.getCellStyle(1, 0)).toEqual({ 167 | backgroundRepeat: "no-repeat", 168 | gridRow: "2 / 3", 169 | gridColumn: "1 / 2", 170 | backgroundPosition: "left center", 171 | backgroundSize: "150px 400px" 172 | }); 173 | // Other side 174 | expect(grid.getCellStyle(0, 1)).toEqual({ 175 | backgroundRepeat: "no-repeat", 176 | gridRow: "1 / 2", 177 | gridColumn: "2 / 3", 178 | backgroundPosition: "center top", 179 | backgroundSize: "300px 300px" 180 | }); 181 | // Grab the center cell 182 | expect(grid.getCellStyle(1, 1)).toEqual({ 183 | backgroundRepeat: "no-repeat", 184 | gridRow: "2 / 3", 185 | gridColumn: "2 / 3", 186 | backgroundPosition: "center center", 187 | backgroundSize: "300px 400px" 188 | }); 189 | }); 190 | }); -------------------------------------------------------------------------------- /packages/npr/src/calc.ts: -------------------------------------------------------------------------------- 1 | import { type Size, type Border } from './types'; 2 | 3 | const sizeStrRe = /(\d+)(px|%)/; 4 | 5 | export function calcReferencedSize(sizestr: string, refsize: number): number { 6 | const match = sizestr.match(sizeStrRe); 7 | 8 | if (!match) { 9 | throw new Error('Invalid size string'); 10 | } 11 | 12 | const size = parseFloat(match[1]); 13 | const unit = match[2]; 14 | 15 | switch (unit) { 16 | case 'px': 17 | return size; 18 | case '%': 19 | return refsize*size/100; 20 | default: 21 | throw new Error('Invalid unit'); 22 | } 23 | } 24 | 25 | export class BorderCalculator { 26 | 27 | _border: Border; 28 | _ref: Size; 29 | 30 | constructor(border: Border, ref: Size) { 31 | this._border = border; 32 | this._ref = ref; 33 | } 34 | 35 | get left(): number { 36 | return calcReferencedSize(this._border.left, this._ref.width); 37 | } 38 | 39 | get right(): number { 40 | return calcReferencedSize(this._border.right, this._ref.width); 41 | } 42 | 43 | get top(): number { 44 | return calcReferencedSize(this._border.top, this._ref.height); 45 | } 46 | 47 | get bottom(): number { 48 | return calcReferencedSize(this._border.bottom, this._ref.height); 49 | } 50 | 51 | get width(): number { 52 | return this.left+this.right; 53 | } 54 | 55 | get height(): number { 56 | return this.top+this.bottom; 57 | } 58 | }; 59 | 60 | export class GridStyleCalculator { 61 | 62 | _imgSize: Size; 63 | _divSize: Size; 64 | _border: BorderCalculator; 65 | _scale: number; 66 | 67 | constructor(imgSize: Size, divSize: Size, border: Border, scale: number = 1.0) { 68 | this._imgSize = imgSize; 69 | this._divSize = divSize; 70 | this._border = new BorderCalculator(border, imgSize); 71 | this._scale = scale; 72 | } 73 | 74 | get gridStyle(): Record { 75 | return { 76 | display: 'grid', 77 | gridTemplateColumns: `${this._border.left*this._scale}px auto ${this._border.right*this._scale}px`, 78 | gridTemplateRows: `${this._border.top*this._scale}px auto ${this._border.bottom*this._scale}px` 79 | }; 80 | } 81 | 82 | getCellStyle(row: number, col: number): Record { 83 | 84 | const bPosV = ['top', 'center', 'bottom'][row]; 85 | const bPosH = ['left', 'center', 'right'][col]; 86 | 87 | let bSizeH = `${this._imgSize.width*this._scale}px`; 88 | let bSizeV = `${this._imgSize.height*this._scale}px`; 89 | 90 | // Size must be adjusted for non-corner cells 91 | if (row === 1) { 92 | const targH = this._divSize.height-this._border.height*this._scale; 93 | const baseH = this._imgSize.height-this._border.height; 94 | bSizeV = `${this._imgSize.height*targH/baseH}px` 95 | if (targH/baseH < 0) { 96 | console.log(this._divSize.height, this._border.height, this._imgSize.height); 97 | } 98 | } 99 | 100 | if (col === 1) { 101 | const targW = this._divSize.width-this._border.width*this._scale; 102 | const baseW = this._imgSize.width-this._border.width; 103 | bSizeH = `${this._imgSize.width*targW/baseW}px` 104 | } 105 | 106 | const cStyle = { 107 | backgroundRepeat: 'no-repeat', 108 | gridRow: `${row+1} / ${row+2}`, 109 | gridColumn: `${col+1} / ${col+2}`, 110 | backgroundPosition: `${bPosH} ${bPosV}`, 111 | backgroundSize: `${bSizeH} ${bSizeV}` 112 | }; 113 | 114 | return cStyle; 115 | } 116 | 117 | } -------------------------------------------------------------------------------- /packages/npr/src/hooks.test.tsx: -------------------------------------------------------------------------------- 1 | import { expect, describe, it, spyOn } from "bun:test"; 2 | import { useImageSize, useElementSize } from "./hooks"; 3 | import { waitFor } from "@testing-library/react"; 4 | import { type RefObject } from "react"; 5 | import { renderHook } from '@testing-library/react'; 6 | 7 | describe("useElementSize", () => { 8 | 9 | it("should detect the correct size from the bounding rect", () => { 10 | class MockRef implements RefObject { 11 | current: HTMLElement; 12 | constructor(el: HTMLElement) { 13 | this.current = el; 14 | } 15 | } 16 | 17 | const divEl = document.createElement('div'); 18 | spyOn(divEl, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 100, 50)); 19 | const mockRef = new MockRef(divEl); 20 | 21 | const { result } = renderHook(() => useElementSize(mockRef)); 22 | expect(result.current).toEqual({width: 100, height: 50}); 23 | }); 24 | 25 | }); 26 | 27 | describe("useImageSize", () => { 28 | it("should detect the correct size from the image", async () => { 29 | 30 | class MockImage { 31 | src: string; 32 | naturalWidth: number; 33 | naturalHeight: number; 34 | 35 | constructor() { 36 | this.src = ''; 37 | this.naturalWidth = 0; 38 | this.naturalHeight = 0; 39 | } 40 | 41 | async decode() { 42 | const [w, h] = this.src.split('x').map((x) => parseInt(x)); 43 | this.naturalWidth = w; 44 | this.naturalHeight = h; 45 | return; 46 | } 47 | } 48 | 49 | // @ts-expect-error Mock class does not implement the full Image interface 50 | spyOn(window, 'Image').mockImplementation(() => new MockImage()); 51 | 52 | let { result } = renderHook(() => useImageSize('20x10')); 53 | waitFor(() => { 54 | expect(result.current).toEqual({width: 20, height: 10}); 55 | }); 56 | 57 | result = renderHook(() => useImageSize('50x100')).result; 58 | waitFor(() => { 59 | expect(result.current).toEqual({width: 50, height: 100}); 60 | }); 61 | 62 | }); 63 | }); -------------------------------------------------------------------------------- /packages/npr/src/hooks.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, useRef } from 'react'; 2 | import { type Size } from './types'; 3 | 4 | /** 5 | * A hook that returns the size of an image given its URL. 6 | * 7 | * @param src The URL of the image 8 | * @returns 9 | */ 10 | export function useImageSize(src: string): Size | null { 11 | const [size, setSize] = useState(null); 12 | 13 | useEffect(() => { 14 | const img = new Image(); 15 | img.src = src; 16 | img.decode().then(() => { 17 | setSize({ width: img.naturalWidth, height: img.naturalHeight }); 18 | }); 19 | }, [src]); 20 | 21 | return size; 22 | } 23 | 24 | /** 25 | * A hook that returns the size of an element given its ref. 26 | * 27 | * @param ref The ref of the element 28 | * @returns 29 | */ 30 | export function useElementSize(ref: React.RefObject): Size | null { 31 | const [size, setSize] = useState(null); 32 | const obsRef = useRef(new ResizeObserver(updateSize)); 33 | 34 | function updateSize() { 35 | if (ref.current) { 36 | const rect = ref.current.getBoundingClientRect(); 37 | const newSize = { 38 | width: rect.width, 39 | height: rect.height, 40 | }; 41 | if (size === null || size.width !== newSize.width || size.height !== newSize.height) { 42 | setSize(newSize); 43 | } 44 | } 45 | } 46 | 47 | useEffect(() => { 48 | if (ref.current) { 49 | obsRef.current.observe(ref.current); 50 | updateSize(); 51 | } 52 | else { 53 | obsRef.current.disconnect(); 54 | } 55 | }, [ref.current]); 56 | 57 | 58 | return size; 59 | } -------------------------------------------------------------------------------- /packages/npr/src/npr.test.tsx: -------------------------------------------------------------------------------- 1 | import { expect, describe, it } from "bun:test"; 2 | import NinePatch from "./npr"; 3 | import { type NinePatchProps } from "./npr"; 4 | import { render, waitFor } from "@testing-library/react"; 5 | import { testFrameURI } from "./test_data"; 6 | 7 | describe("NinePatch", () => { 8 | function TestComponent(props: NinePatchProps) { 9 | return ( 10 | 11 |
Test
12 |
13 | ); 14 | } 15 | 16 | it("should render the correct layout", async () => { 17 | 18 | // Render the component 19 | const { container } = render(); 25 | 26 | // Retrieve the div element 27 | const div = container.querySelector(".npr-grid-rect") as HTMLDivElement; 28 | expect(div).not.toBeNull(); 29 | await waitFor(() => { 30 | expect(div.style.display).toBe("grid"); 31 | 32 | // It should have nine children 33 | const children = div.querySelectorAll(".npr-grid-cell"); 34 | expect(children.length).toBe(9); 35 | 36 | // The content should be in the center 37 | const content = children[4].querySelector("#test-content"); 38 | expect(content).not.toBeNull(); 39 | expect(content?.textContent).toBe("Test"); 40 | 41 | // All other children should be empty 42 | for (let i = 0; i < 9; i++) { 43 | if (i !== 4) { 44 | const child = children[i]; 45 | expect(child.innerHTML).toBe(""); 46 | } 47 | } 48 | }); 49 | }); 50 | }); -------------------------------------------------------------------------------- /packages/npr/src/npr.tsx: -------------------------------------------------------------------------------- 1 | import { type PropsWithChildren, useRef } from 'react'; 2 | import { useImageSize, useElementSize } from './hooks'; 3 | import { GridStyleCalculator } from './calc'; 4 | 5 | export type NinePatchProps = { 6 | src: string; 7 | borderLeft?: string; 8 | borderRight?: string; 9 | borderTop?: string; 10 | borderBottom?: string; 11 | scale?: number; 12 | pixelPerfect?: boolean; 13 | } 14 | 15 | export default function NinePatch({ 16 | children, 17 | src, 18 | scale = 1, 19 | borderLeft = '33%', 20 | borderRight = '33%', 21 | borderTop = '33%', 22 | borderBottom = '33%', 23 | pixelPerfect = false 24 | }: PropsWithChildren) { 25 | 26 | const divRef = useRef(null); 27 | 28 | const imgSize = useImageSize(src); 29 | const divSize = useElementSize(divRef); 30 | 31 | let styleCalc = null; 32 | if ( 33 | imgSize !== null && 34 | divSize !== null 35 | ) { 36 | styleCalc = new GridStyleCalculator(imgSize, divSize, { 37 | left: borderLeft, 38 | right: borderRight, 39 | top: borderTop, 40 | bottom: borderBottom 41 | }, scale); 42 | } 43 | 44 | const contStyle = {...styleCalc?.gridStyle}; 45 | // Pixel perfect scaling 46 | if (pixelPerfect) { 47 | contStyle.imageRendering = "pixelated"; 48 | } 49 | 50 | 51 | return
52 | { 53 | [0, 1, 2].map(x => { 54 | return [0, 1, 2].map(y => { 55 | return
57 | {(x === 1 && y === 1) ? children : null} 58 |
; 59 | }); 60 | }) 61 | } 62 |
; 63 | } -------------------------------------------------------------------------------- /packages/npr/src/test_data/index.ts: -------------------------------------------------------------------------------- 1 | const basePath = import.meta.path.replace("index.ts", ""); 2 | const testFramePath = basePath + "testframe_20x10.png"; 3 | 4 | async function loadFileAsBytestring(path: string): Promise { 5 | const file = Bun.file(path); 6 | const bytes = await file.bytes(); 7 | let textstr = ""; 8 | for (const byte of bytes) { 9 | textstr += ('0' + (byte & 0xFF).toString(16)).slice(-2); 10 | } 11 | return textstr; 12 | } 13 | 14 | export const testFrameBytes = await loadFileAsBytestring(testFramePath); 15 | export const testFrameURI = "data:image/png;base64," + testFrameBytes; -------------------------------------------------------------------------------- /packages/npr/src/test_data/testframe_20x10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stur86/nine-patch-react/bfa3291763c6c51e352bc76c32467524b83d1422/packages/npr/src/test_data/testframe_20x10.png -------------------------------------------------------------------------------- /packages/npr/src/types.ts: -------------------------------------------------------------------------------- 1 | export type Size = { 2 | width: number; 3 | height: number; 4 | }; 5 | 6 | export type Border = { 7 | left: string; 8 | right: string; 9 | top: string; 10 | bottom: string; 11 | }; -------------------------------------------------------------------------------- /packages/npr/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext", "DOM"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | 22 | // Some stricter flags (disabled by default) 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "noPropertyAccessFromIndexSignature": false 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext", "DOM"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | 22 | // Some stricter flags (disabled by default) 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "noPropertyAccessFromIndexSignature": false 26 | } 27 | } 28 | --------------------------------------------------------------------------------