├── .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 | 
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 |
40 |
41 | );
42 | }
43 |
44 | function App() {
45 |
46 | return (
47 | <>
48 |
49 |
50 |
51 | Nine Patch React
52 |
53 |
54 |
65 |
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 | How it works
95 | Installation and usage
96 | Showcase
97 | Properties
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 |
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 | Property
199 | Type
200 | Description
201 |
202 |
203 |
204 |
205 | src
206 | string
207 | Path to the image to use as a frame.
208 |
209 |
210 | scale
211 | number (Optional)
212 | Scale factor for the image. Default is 1.
213 |
214 |
215 | pixelPerfect
216 | boolean (Optional)
217 | Whether to use pixel-perfect scaling. Default is false.
218 |
219 |
220 | borderTop
221 | string (Optional)
222 | Size of the top border. Can be in pixels or percentage. Default is 33%.
223 |
224 |
225 | borderRight
226 | string (Optional)
227 | Size of the right border. Can be in pixels or percentage. Default is 33%.
228 |
229 |
230 | borderBottom
231 | string (Optional)
232 | Size of the bottom border. Can be in pixels or percentage. Default is 33%.
233 |
234 |
235 | borderLeft
236 | string (Optional)
237 | Size of the left border. Can be in pixels or percentage. Default is 33%.
238 |
239 |
240 |
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 |
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 |
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 image of 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 the borders
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 |
--------------------------------------------------------------------------------