├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github ├── renovate.json └── workflows │ └── main.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .prettierrc.json ├── .releaserc.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── commitlint.config.js ├── examples └── test-studio │ ├── .gitignore │ ├── package.json │ └── sanity.config.ts ├── lint-staged.config.js ├── package-lock.json ├── package.config.ts ├── package.json ├── sanity.json ├── src ├── Feedback.tsx ├── ImageHotspotArray.tsx ├── Spot.tsx ├── index.ts ├── plugin.tsx ├── useDebouncedCallback.ts └── useResizeObserver.ts ├── tsconfig.dist.json ├── tsconfig.json ├── tsconfig.settings.json └── v2-incompatible.js /.editorconfig: -------------------------------------------------------------------------------- 1 | ; editorconfig.org 2 | root = true 3 | charset= utf8 4 | 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | indent_style = space 10 | indent_size = 2 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .eslintrc.js 2 | commitlint.config.js 3 | dist 4 | lint-staged.config.js 5 | package.config.ts 6 | *.js 7 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "node": true, 5 | "browser": true 6 | }, 7 | "extends": [ 8 | "sanity", 9 | "sanity/typescript", 10 | "sanity/react", 11 | "plugin:react-hooks/recommended", 12 | "plugin:prettier/recommended", 13 | "plugin:react/jsx-runtime" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "github>sanity-io/renovate-config", 5 | "github>sanity-io/renovate-config:studio-v3" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI & Release 3 | 4 | # Workflow name based on selected inputs. Fallback to default Github naming when expression evaluates to empty string 5 | run-name: >- 6 | ${{ 7 | inputs.release && inputs.test && 'Build ➤ Test ➤ Publish to NPM' || 8 | inputs.release && !inputs.test && 'Build ➤ Skip Tests ➤ Publish to NPM' || 9 | github.event_name == 'workflow_dispatch' && inputs.test && 'Build ➤ Test' || 10 | github.event_name == 'workflow_dispatch' && !inputs.test && 'Build ➤ Skip Tests' || 11 | '' 12 | }} 13 | 14 | on: 15 | # Build on pushes branches that have a PR (including drafts) 16 | pull_request: 17 | # Build on commits pushed to branches without a PR if it's in the allowlist 18 | push: 19 | branches: [main, studio-v2] 20 | # https://docs.github.com/en/actions/managing-workflow-runs/manually-running-a-workflow 21 | workflow_dispatch: 22 | inputs: 23 | test: 24 | description: Run tests 25 | required: true 26 | default: true 27 | type: boolean 28 | release: 29 | description: Release new version 30 | required: true 31 | default: false 32 | type: boolean 33 | 34 | concurrency: 35 | # On PRs builds will cancel if new pushes happen before the CI completes, as it defines `github.head_ref` and gives it the name of the branch the PR wants to merge into 36 | # Otherwise `github.run_id` ensures that you can quickly merge a queue of PRs without causing tests to auto cancel on any of the commits pushed to main. 37 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 38 | cancel-in-progress: true 39 | 40 | jobs: 41 | log-the-inputs: 42 | name: Log inputs 43 | runs-on: ubuntu-latest 44 | steps: 45 | - run: | 46 | echo "Inputs: $INPUTS" 47 | env: 48 | INPUTS: ${{ toJSON(inputs) }} 49 | 50 | build: 51 | runs-on: ubuntu-latest 52 | name: Lint & Build 53 | steps: 54 | - uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # tag=v3 55 | - uses: actions/setup-node@8c91899e586c5b171469028077307d293428b516 # tag=v3 56 | with: 57 | cache: npm 58 | node-version: lts/* 59 | - run: npm ci 60 | # Linting can be skipped 61 | - run: npm run lint --if-present 62 | if: github.event.inputs.test != 'false' 63 | # But not the build script, as semantic-release will crash if this command fails so it makes sense to test it early 64 | - run: npm run prepublishOnly --if-present 65 | 66 | test: 67 | needs: build 68 | # The test matrix can be skipped, in case a new release needs to be fast-tracked and tests are already passing on main 69 | if: github.event.inputs.test != 'false' 70 | runs-on: ${{ matrix.os }} 71 | name: Node.js ${{ matrix.node }} / ${{ matrix.os }} 72 | strategy: 73 | # A test failing on windows doesn't mean it'll fail on macos. It's useful to let all tests run to its completion to get the full picture 74 | fail-fast: false 75 | matrix: 76 | # Run the testing suite on each major OS with the latest LTS release of Node.js 77 | os: [macos-latest, ubuntu-latest, windows-latest] 78 | node: [lts/*] 79 | # It makes sense to also test the oldest, and latest, versions of Node.js, on ubuntu-only since it's the fastest CI runner 80 | include: 81 | - os: ubuntu-latest 82 | # Test the oldest LTS release of Node that's still receiving bugfixes and security patches, versions older than that have reached End-of-Life 83 | node: lts/-2 84 | - os: ubuntu-latest 85 | # Test the actively developed version that will become the latest LTS release next October 86 | node: current 87 | steps: 88 | # It's only necessary to do this for windows, as mac and ubuntu are sane OS's that already use LF 89 | - name: Set git to use LF 90 | if: matrix.os == 'windows-latest' 91 | run: | 92 | git config --global core.autocrlf false 93 | git config --global core.eol lf 94 | - uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # tag=v3 95 | - uses: actions/setup-node@8c91899e586c5b171469028077307d293428b516 # tag=v3 96 | with: 97 | cache: npm 98 | node-version: ${{ matrix.node }} 99 | - run: npm i 100 | - run: npm test --if-present 101 | 102 | release: 103 | needs: [build, test] 104 | # only run if opt-in during workflow_dispatch 105 | if: always() && github.event.inputs.release == 'true' && needs.build.result != 'failure' && needs.test.result != 'failure' && needs.test.result != 'cancelled' 106 | runs-on: ubuntu-latest 107 | name: Semantic release 108 | steps: 109 | - uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # tag=v3 110 | with: 111 | # Need to fetch entire commit history to 112 | # analyze every commit since last release 113 | fetch-depth: 0 114 | - uses: actions/setup-node@8c91899e586c5b171469028077307d293428b516 # tag=v3 115 | with: 116 | cache: npm 117 | node-version: lts/* 118 | - run: npm ci 119 | # Branches that will release new versions are defined in .releaserc.json 120 | - run: npx semantic-release 121 | # Don't allow interrupting the release step if the job is cancelled, as it can lead to an inconsistent state 122 | # e.g. git tags were pushed but it exited before `npm publish` 123 | if: always() 124 | env: 125 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 126 | NPM_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} 127 | # Re-run semantic release with rich logs if it failed to publish for easier debugging 128 | - run: npx semantic-release --dry-run --debug 129 | if: failure() 130 | env: 131 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 132 | NPM_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} 133 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | # macOS finder cache file 40 | .DS_Store 41 | 42 | # VS Code settings 43 | .vscode 44 | 45 | # IntelliJ 46 | .idea 47 | *.iml 48 | 49 | # Cache 50 | .cache 51 | 52 | # Yalc 53 | .yalc 54 | yalc.lock 55 | 56 | ##npm package zips 57 | *.tgz 58 | 59 | # Compiled plugin 60 | dist 61 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit "" 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "printWidth": 100, 4 | "bracketSpacing": false, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sanity/semantic-release-preset", 3 | "branches": ["main", {"name": "studio-v2", "channel": "studio-v2", "range": "0.x.x"}] 4 | } 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # 📓 Changelog 4 | 5 | All notable changes to this project will be documented in this file. See 6 | [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 7 | 8 | ## [2.2.0](https://github.com/sanity-io/sanity-plugin-hotspot-array/compare/v2.1.2...v2.2.0) (2025-03-07) 9 | 10 | ### Features 11 | 12 | - add react 19 to peer deps ([646c253](https://github.com/sanity-io/sanity-plugin-hotspot-array/commit/646c25367c2fcdd4941dc82b89123a1bb1a6a2a8)) 13 | 14 | ## [2.1.2](https://github.com/sanity-io/sanity-plugin-hotspot-array/compare/v2.1.1...v2.1.2) (2024-12-04) 15 | 16 | ### Bug Fixes 17 | 18 | - reverse negated condition ([bb0d315](https://github.com/sanity-io/sanity-plugin-hotspot-array/commit/bb0d3153ebf2bf9a67e2b07808a0259a6cf332be)) 19 | 20 | ## [2.1.1](https://github.com/sanity-io/sanity-plugin-hotspot-array/compare/v2.1.0...v2.1.1) (2024-11-19) 21 | 22 | ### Bug Fixes 23 | 24 | - prevent tooltip from resetting tooltip position ([b9ec268](https://github.com/sanity-io/sanity-plugin-hotspot-array/commit/b9ec268363957ec60304fc9f1dcc99d194b55893)) 25 | 26 | ## [2.1.0](https://github.com/sanity-io/sanity-plugin-hotspot-array/compare/v2.0.1...v2.1.0) (2024-10-15) 27 | 28 | ### Features 29 | 30 | - support initial values ([d9cd56c](https://github.com/sanity-io/sanity-plugin-hotspot-array/commit/d9cd56c83f4cf36ae296bbc0a56aca9cb8478446)) 31 | - support initial values in image click ([b92c1f8](https://github.com/sanity-io/sanity-plugin-hotspot-array/commit/b92c1f892ec168da9c50af1f3df0540ce54a1080)) 32 | 33 | ### Bug Fixes 34 | 35 | - package lock weirdness ([da28862](https://github.com/sanity-io/sanity-plugin-hotspot-array/commit/da2886248b30bf9f30602606d9f938d93831d1a0)) 36 | 37 | ## [2.0.1](https://github.com/sanity-io/sanity-plugin-hotspot-array/compare/v2.0.0...v2.0.1) (2024-10-09) 38 | 39 | ### Bug Fixes 40 | 41 | - **deps:** upgrade `@sanity/asset-utils` to ^2 ([#29](https://github.com/sanity-io/sanity-plugin-hotspot-array/issues/29)) ([7c0c19d](https://github.com/sanity-io/sanity-plugin-hotspot-array/commit/7c0c19d5eba0ac54bb27721a6706a3b2fbe36630)) 42 | 43 | ## [2.0.0](https://github.com/sanity-io/sanity-plugin-hotspot-array/compare/v1.0.1...v2.0.0) (2024-04-17) 44 | 45 | ### ⚠ BREAKING CHANGES 46 | 47 | - @sanity/ui ^2 and styled-components ^6.1 are new peer deps 48 | 49 | * New updated build process using our latest `@sanity/pkg-utils` 50 | * Updated to use `@sanity/ui` v2 51 | * Updated `@sanity/image-url` to v1.0.2 for compatibilty with newer `@sanity/client`s 52 | * Upgraded `framer-motion` to v11 53 | * Switched to `lodash-es` 54 | * Updated dev deps for updated local dev (internal) 55 | 56 | ### Features 57 | 58 | - v2; updated plugin with updated compatibilty ([#22](https://github.com/sanity-io/sanity-plugin-hotspot-array/issues/22)) ([0af19be](https://github.com/sanity-io/sanity-plugin-hotspot-array/commit/0af19bee25a61d0e6b5ca4005fc3ccca33cbc3ef)) 59 | 60 | ## [1.0.1](https://github.com/sanity-io/sanity-plugin-hotspot-array/compare/v1.0.0...v1.0.1) (2022-11-25) 61 | 62 | ### Bug Fixes 63 | 64 | - **deps:** sanity ^3.0.0 (works with rc.3) ([8fa35e3](https://github.com/sanity-io/sanity-plugin-hotspot-array/commit/8fa35e30633edd97a9e437a2cf130373b6ca3e61)) 65 | 66 | ## [1.0.0](https://github.com/sanity-io/sanity-plugin-hotspot-array/compare/v0.0.8...v1.0.0) (2022-11-16) 67 | 68 | ### ⚠ BREAKING CHANGES 69 | 70 | - this version does not work in Sanity Studio V2. 71 | It is built for sanity 3.0.0-rc.2 -> 72 | 73 | ### Features 74 | 75 | - initial Sanity Studio V3 release ([4c6be44](https://github.com/sanity-io/sanity-plugin-hotspot-array/commit/4c6be44a9dd62d776d633ac493264bd6478109df)) 76 | - initial v3 version ([df513e8](https://github.com/sanity-io/sanity-plugin-hotspot-array/commit/df513e8597862226af5464b2411cc925c0a05744)) 77 | 78 | ### Bug Fixes 79 | 80 | - compiled for sanity 3.0.0-rc.0 ([3fd605f](https://github.com/sanity-io/sanity-plugin-hotspot-array/commit/3fd605f993de5631410ed7e25d55af39d9f36cca)) 81 | - **deps:** pkg-utils & @sanity/plugin-kit ([1f23928](https://github.com/sanity-io/sanity-plugin-hotspot-array/commit/1f239289bddaede28ad5098bdcfeb98fd87eeb76)) 82 | 83 | ## [0.1.0-v3-studio.3](https://github.com/sanity-io/sanity-plugin-hotspot-array/compare/v0.1.0-v3-studio.2...v0.1.0-v3-studio.3) (2022-11-04) 84 | 85 | ### Bug Fixes 86 | 87 | - **deps:** pkg-utils & @sanity/plugin-kit ([1f23928](https://github.com/sanity-io/sanity-plugin-hotspot-array/commit/1f239289bddaede28ad5098bdcfeb98fd87eeb76)) 88 | 89 | ## [0.1.0-v3-studio.2](https://github.com/sanity-io/sanity-plugin-hotspot-array/compare/v0.1.0-v3-studio.1...v0.1.0-v3-studio.2) (2022-11-03) 90 | 91 | ### Bug Fixes 92 | 93 | - compiled for sanity 3.0.0-rc.0 ([3fd605f](https://github.com/sanity-io/sanity-plugin-hotspot-array/commit/3fd605f993de5631410ed7e25d55af39d9f36cca)) 94 | 95 | ## [0.1.0-v3-studio.1](https://github.com/sanity-io/sanity-plugin-hotspot-array/compare/v0.0.8...v0.1.0-v3-studio.1) (2022-10-31) 96 | 97 | ### Features 98 | 99 | - initial v3 version ([df513e8](https://github.com/sanity-io/sanity-plugin-hotspot-array/commit/df513e8597862226af5464b2411cc925c0a05744)) 100 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Sanity.io 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sanity-plugin-hotspot-array 2 | 3 | >This is a **Sanity Studio v3** plugin. 4 | > For the v2 version, please refer to the [v2-branch](https://github.com/sanity-io/sanity-plugin-hotspot-array/tree/studio-v2). 5 | 6 | ## What is it? 7 | 8 | A configurable Custom Input for Arrays that will add and update items by clicking on an Image 9 | 10 | 11 | 12 | ## Installation 13 | 14 | ``` 15 | npm install --save sanity-plugin-hotspot-array 16 | ``` 17 | 18 | or 19 | 20 | ``` 21 | yarn add sanity-plugin-hotspot-array 22 | ``` 23 | 24 | 25 | ## Usage 26 | 27 | Add it as a plugin in sanity.config.ts (or .js): 28 | 29 | ```js 30 | import { imageHotspotArrayPlugin } from "sanity-plugin-hotspot-array"; 31 | 32 | export default defineConfig({ 33 | // ... 34 | plugins: [ 35 | imageHotspotArrayPlugin(), 36 | ] 37 | }) 38 | ``` 39 | 40 | Now you will have `imageHotspot` available as an options on `array` fields: 41 | 42 | ```js 43 | import {defineType, defineField} from 'sanity' 44 | 45 | export const productSchema = defineType({ 46 | name: `product`, 47 | title: `Product`, 48 | type: `document`, 49 | fields: [ 50 | defineField({ 51 | name: `hotspots`, 52 | type: `array`, 53 | of: [ 54 | // see `Spot object` setup below 55 | ], 56 | options: { 57 | // plugin adds support for this option 58 | imageHotspot: { 59 | // see `Image and description path` setup below 60 | imagePath: `featureImage`, 61 | descriptionPath: `details`, 62 | // see `Custom tooltip` setup below 63 | tooltip: undefined, 64 | } 65 | }, 66 | }), 67 | // ...all your other fields 68 | // ...of which one should be featureImage in this example 69 | ], 70 | }) 71 | ``` 72 | There is no need to provide an explicit input component, as that is handled by the plugin. 73 | 74 | The plugin makes a number of assumptions to add and update data in the array. Including: 75 | 76 | - The `array` field is an array of objects, with a single object type 77 | - The object contains two number fields named `x` and `y` 78 | - You'll want to save those values as % from the top left of the image 79 | - The same document contains the image you want to add hotspots to 80 | 81 | ### Image path 82 | 83 | The custom input has the current values of all fields in the document, and so can "pick" the image out of the document by its path. 84 | 85 | For example, if you want to add hotspots to an image, and that image is uploaded to the field `featuredImage`, your fields `options` would look like: 86 | 87 | ```js 88 | options: { 89 | imageHotspot: { 90 | imagePath: `featureImage` 91 | } 92 | } 93 | ``` 94 | 95 | To pick the image out of the hotspot-array parent object, use 96 | ```js 97 | options: { 98 | imageHotspot: { 99 | pathRoot: 'parent' 100 | } 101 | } 102 | ``` 103 | 104 | ### Description path 105 | 106 | The custom input can also pre-fill a string or text field with a description of the position of the spot to make them easier to identify. 107 | Add a path **relative to the spot object** for this field. 108 | 109 | ```js 110 | options: { 111 | imageHotspot: { 112 | descriptionPath: `details` 113 | } 114 | } 115 | ``` 116 | 117 | ### Spot object 118 | 119 | Here's an example object schema complete with initial values, validation, fieldsets and a styled preview. 120 | 121 | ```js 122 | defineField({ 123 | name: 'spot', 124 | type: 'object', 125 | fieldsets: [{name: 'position', options: {columns: 2}}], 126 | fields: [ 127 | {name: 'details', type: 'text', rows: 2}, 128 | { 129 | name: 'x', 130 | type: 'number', 131 | readOnly: true, 132 | fieldset: 'position', 133 | initialValue: 50, 134 | validation: (Rule) => Rule.required().min(0).max(100), 135 | }, 136 | { 137 | name: 'y', 138 | type: 'number', 139 | readOnly: true, 140 | fieldset: 'position', 141 | initialValue: 50, 142 | validation: (Rule) => Rule.required().min(0).max(100), 143 | }, 144 | ], 145 | preview: { 146 | select: { 147 | title: 'details', 148 | x: 'x', 149 | y: 'y', 150 | }, 151 | prepare({title, x, y}) { 152 | return { 153 | title, 154 | subtitle: x && y ? `${x}% x ${y}%` : `No position set`, 155 | } 156 | }, 157 | }, 158 | }) 159 | ``` 160 | 161 | ## Custom tooltip 162 | 163 | You can customise the Tooltip to display any Component, which will receive `value` (the hotspot value with x and y), 164 | `schemaType` (schemaType of the hotspot value), and `renderPreview` (callback for rendering Studio preview). 165 | 166 | ### Example 1 - use default hotspot preview 167 | 168 | ```tsx 169 | import { Box } from "@sanity/ui"; 170 | import { HotspotTooltipProps } from "sanity-plugin-hotspot-array"; 171 | 172 | export function HotspotPreview({ 173 | value, 174 | schemaType, 175 | renderPreview, 176 | }: HotspotTooltipProps) { 177 | return ( 178 | 179 | {renderPreview({ 180 | value, 181 | schemaType, 182 | layout: "default", 183 | })} 184 | 185 | ); 186 | } 187 | ``` 188 | 189 | Then back in your schema definition 190 | 191 | ```js 192 | options: { 193 | imageHotspot: { 194 | tooltip: HotspotPreview 195 | } 196 | } 197 | ``` 198 | 199 | ### Example 2 - reference value in hotspot 200 | In this example our `value` object has a `reference` field to the `product` schema type, and will show a document preview. 201 | 202 | ```jsx 203 | import {useSchema }from 'sanity' 204 | import {Box} from '@sanity/ui' 205 | 206 | export function ProductPreview({value, renderPreview}) { 207 | const productSchemaType = useSchema().get('product') 208 | return ( 209 | 210 | {value?.product?._ref ? ( 211 | renderPreview({ 212 | value, 213 | schemaType: productSchemaType, 214 | layout: "default" 215 | }) 216 | ) : ( 217 | `No reference selected` 218 | )} 219 | 220 | ) 221 | } 222 | ``` 223 | 224 | Then back in your schema definition 225 | 226 | ```js 227 | options: { 228 | imageHotspot: { 229 | tooltip: ProductPreview 230 | } 231 | } 232 | ``` 233 | 234 | ## License 235 | 236 | MIT-licensed. See LICENSE. 237 | 238 | ## Develop & test 239 | 240 | This plugin uses [@sanity/plugin-kit](https://github.com/sanity-io/plugin-kit) 241 | with default configuration for build & watch scripts. 242 | 243 | See [Testing a plugin in Sanity Studio](https://github.com/sanity-io/plugin-kit#testing-a-plugin-in-sanity-studio) 244 | on how to run this plugin with hotreload in the studio. 245 | 246 | ### Release new version 247 | 248 | Run ["CI & Release" workflow](https://github.com/sanity-io/sanity-plugin-hotspot-array/actions/workflows/main.yml). 249 | Make sure to select the main branch and check "Release new version". 250 | 251 | Semantic release will only release on configured branches, so it is safe to run release on any branch. 252 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | } 4 | -------------------------------------------------------------------------------- /examples/test-studio/.gitignore: -------------------------------------------------------------------------------- 1 | /.sanity 2 | -------------------------------------------------------------------------------- /examples/test-studio/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-studio", 3 | "version": "1.0.0", 4 | "private": true, 5 | "description": "", 6 | "main": "index.js", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1", 9 | "dev": "sanity dev" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "MIT", 14 | "dependencies": { 15 | "react": "^18.2.0", 16 | "sanity": "^3.38.0", 17 | "styled-components": "^6.1.8" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /examples/test-studio/sanity.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig, defineField, defineType} from 'sanity' 2 | import {structureTool} from 'sanity/structure' 3 | 4 | import {imageHotspotArrayPlugin} from '../../dist/index.mjs' 5 | 6 | export default defineConfig({ 7 | name: 'default', 8 | title: 'Test Studio', 9 | projectId: 'ppsg7ml5', 10 | dataset: 'test', 11 | plugins: [structureTool(), imageHotspotArrayPlugin()], 12 | schema: { 13 | types: [ 14 | defineField({ 15 | name: 'hotspot', 16 | type: 'object', 17 | fieldsets: [{name: 'position', options: {columns: 2}}], 18 | fields: [ 19 | {name: 'details', type: 'text', rows: 2}, 20 | {name: 'booleanTest', type: 'boolean', initialValue: true}, 21 | {name: 'stringTest', type: 'string', initialValue: 'Hello, World!'}, 22 | { 23 | name: 'x', 24 | type: 'number', 25 | readOnly: true, 26 | fieldset: 'position', 27 | initialValue: 50, 28 | validation: (Rule) => Rule.required().min(0).max(100), 29 | }, 30 | { 31 | name: 'y', 32 | type: 'number', 33 | readOnly: true, 34 | fieldset: 'position', 35 | initialValue: 50, 36 | validation: (Rule) => Rule.required().min(0).max(100), 37 | }, 38 | ], 39 | preview: { 40 | select: { 41 | title: 'details', 42 | x: 'x', 43 | y: 'y', 44 | }, 45 | prepare({title, x, y}) { 46 | return { 47 | title, 48 | subtitle: x && y ? `${x}% x ${y}%` : `No position set`, 49 | } 50 | }, 51 | }, 52 | }), 53 | defineType({ 54 | name: 'hotspotArrayTest', 55 | type: 'document', 56 | fields: [ 57 | defineField({name: 'title', type: 'string'}), 58 | defineField({name: 'featureImage', type: 'image'}), 59 | defineField({ 60 | name: 'hotspots', 61 | type: 'array', 62 | of: [{type: 'hotspot'}], 63 | options: { 64 | imageHotspot: { 65 | imagePath: 'featureImage', 66 | descriptionPath: 'details', 67 | }, 68 | }, 69 | }), 70 | ], 71 | }), 72 | ], 73 | }, 74 | }) 75 | -------------------------------------------------------------------------------- /lint-staged.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '**/*.{js,jsx}': ['eslint'], 3 | '**/*.{ts,tsx}': ['eslint', () => 'tsc --noEmit'], 4 | } 5 | -------------------------------------------------------------------------------- /package.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig} from '@sanity/pkg-utils' 2 | 3 | export default defineConfig({ 4 | dist: 'dist', 5 | tsconfig: 'tsconfig.dist.json', 6 | 7 | // Remove this block to enable strict export validation 8 | extract: { 9 | rules: { 10 | 'ae-forgotten-export': 'off', 11 | 'ae-incompatible-release-tags': 'off', 12 | 'ae-internal-missing-underscore': 'off', 13 | 'ae-missing-release-tag': 'off', 14 | }, 15 | }, 16 | }) 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sanity-plugin-hotspot-array", 3 | "version": "2.2.0", 4 | "workspaces": [ 5 | "./examples/*" 6 | ], 7 | "description": "A configurable Custom Input for Arrays that will add and update items by clicking on an Image", 8 | "keywords": [ 9 | "sanity", 10 | "sanity-plugin" 11 | ], 12 | "homepage": "https://github.com/sanity-io/sanity-plugin-hotspot-array#readme", 13 | "bugs": { 14 | "url": "https://github.com/sanity-io/sanity-plugin-hotspot-array/issues" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git@github.com:sanity-io/sanity-plugin-hotspot-array.git" 19 | }, 20 | "license": "MIT", 21 | "author": "Sanity.io ", 22 | "sideEffects": false, 23 | "type": "commonjs", 24 | "exports": { 25 | ".": { 26 | "source": "./src/index.ts", 27 | "import": "./dist/index.mjs", 28 | "default": "./dist/index.js" 29 | }, 30 | "./package.json": "./package.json" 31 | }, 32 | "main": "./dist/index.js", 33 | "types": "./dist/index.d.ts", 34 | "files": [ 35 | "src", 36 | "dist", 37 | "v2-incompatible.js", 38 | "sanity.json" 39 | ], 40 | "scripts": { 41 | "prebuild": "npm run clean && plugin-kit verify-package --silent && pkg-utils", 42 | "build": "plugin-kit verify-package --silent && pkg-utils build --strict --check --clean", 43 | "clean": "rimraf dist", 44 | "link-watch": "plugin-kit link-watch", 45 | "lint": "eslint .", 46 | "lint:fix": "eslint . --fix", 47 | "prepare": "husky install", 48 | "prepublishOnly": "npm run build", 49 | "watch": "pkg-utils watch --strict" 50 | }, 51 | "browserslist": "extends @sanity/browserslist-config", 52 | "dependencies": { 53 | "@sanity/asset-utils": "^2.0.6", 54 | "@sanity/image-url": "^1.0.2", 55 | "@sanity/incompatible-plugin": "^1.0.4", 56 | "@sanity/util": "^3.78.1", 57 | "framer-motion": "^12.4.10", 58 | "lodash-es": "^4.17.21" 59 | }, 60 | "devDependencies": { 61 | "@commitlint/cli": "^19.2.2", 62 | "@commitlint/config-conventional": "^19.2.2", 63 | "@sanity/browserslist-config": "^1.0.3", 64 | "@sanity/pkg-utils": "^6.7.0", 65 | "@sanity/plugin-kit": "^4.0.4", 66 | "@sanity/semantic-release-preset": "^4.1.7", 67 | "@types/lodash-es": "^4.17.12", 68 | "@typescript-eslint/eslint-plugin": "^7.7.0", 69 | "@typescript-eslint/parser": "^7.7.0", 70 | "eslint": "^8.57.0", 71 | "eslint-config-prettier": "^9.1.0", 72 | "eslint-config-sanity": "^7.1.2", 73 | "eslint-plugin-prettier": "^5.1.3", 74 | "eslint-plugin-react": "^7.34.1", 75 | "eslint-plugin-react-hooks": "^4.6.0", 76 | "husky": "^8.0.1", 77 | "lint-staged": "^13.0.3", 78 | "prettier": "^3.2.5", 79 | "prettier-plugin-packagejson": "^2.5.0", 80 | "react": "^18", 81 | "rimraf": "^5.0.5", 82 | "sanity": "^3.0.0", 83 | "typescript": "^5.4.5" 84 | }, 85 | "peerDependencies": { 86 | "@sanity/ui": "^2", 87 | "react": "^18.3 || ^19", 88 | "sanity": "^3", 89 | "styled-components": "^6.1" 90 | }, 91 | "engines": { 92 | "node": ">=18" 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /sanity.json: -------------------------------------------------------------------------------- 1 | { 2 | "parts": [ 3 | { 4 | "implements": "part:@sanity/base/sanity-root", 5 | "path": "./v2-incompatible.js" 6 | } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /src/Feedback.tsx: -------------------------------------------------------------------------------- 1 | import {Card, Text} from '@sanity/ui' 2 | import {type ReactNode} from 'react' 3 | 4 | export default function Feedback({children}: {children: ReactNode}): ReactNode { 5 | return ( 6 | 7 | {children} 8 | 9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /src/ImageHotspotArray.tsx: -------------------------------------------------------------------------------- 1 | import {getImageDimensions} from '@sanity/asset-utils' 2 | import imageUrlBuilder from '@sanity/image-url' 3 | import {Card, Flex, Stack} from '@sanity/ui' 4 | import {randomKey} from '@sanity/util/content' 5 | import {get} from 'lodash-es' 6 | import {type MouseEvent, type ReactNode, useCallback, useMemo, useRef, useState} from 'react' 7 | import { 8 | ArrayOfObjectsInputProps, 9 | ImageValue, 10 | insert, 11 | ObjectSchemaType, 12 | PatchEvent, 13 | SchemaType, 14 | set, 15 | setIfMissing, 16 | useClient, 17 | useFormValue, 18 | useResolveInitialValueForType, 19 | } from 'sanity' 20 | 21 | import Feedback from './Feedback' 22 | import {ImageHotspotOptions} from './plugin' 23 | import Spot from './Spot' 24 | import {useDebouncedCallback} from './useDebouncedCallback' 25 | import {useResizeObserver} from './useResizeObserver' 26 | 27 | const imageStyle = {width: `100%`, height: `auto`} 28 | 29 | const VALID_ROOT_PATHS = ['document', 'parent'] 30 | 31 | export type FnHotspotMove = (key: string, x: number, y: number) => void 32 | 33 | export type HotspotItem = { 34 | _key: string 35 | _type: string 36 | x: number 37 | y: number 38 | } & HotspotFields 39 | 40 | export function ImageHotspotArray( 41 | props: ArrayOfObjectsInputProps & {imageHotspotOptions: ImageHotspotOptions}, 42 | ): ReactNode { 43 | const {value, onChange, imageHotspotOptions, schemaType, renderPreview} = props 44 | 45 | const sanityClient = useClient({apiVersion: '2022-01-01'}) 46 | 47 | const imageHotspotPathRoot = useMemo(() => { 48 | const pathRoot = VALID_ROOT_PATHS.includes(imageHotspotOptions.pathRoot ?? '') 49 | ? imageHotspotOptions.pathRoot 50 | : 'document' 51 | return pathRoot === 'document' ? [] : props.path.slice(0, -1) 52 | }, [imageHotspotOptions.pathRoot, props.path]) 53 | 54 | const rootObject = useFormValue(imageHotspotPathRoot) 55 | 56 | const resolveInitialValueForType = useResolveInitialValueForType() 57 | const resolveInitialValue = useCallback( 58 | async (type: ObjectSchemaType) => { 59 | return resolveInitialValueForType(type as unknown as SchemaType, {}) 60 | .then((initialValue) => { 61 | return initialValue 62 | }) 63 | .catch(() => { 64 | return undefined 65 | }) 66 | }, 67 | [resolveInitialValueForType], 68 | ) 69 | 70 | /** 71 | * Finding the image from the imageHotspotPathRoot (defaults to document), 72 | * using the path from the hotspot's `options` field 73 | * 74 | * when changes in imageHotspotPathRoot (e.g. document) occur, 75 | * check if there are any changes to the hotspotImage and update the reference 76 | */ 77 | const hotspotImage = useMemo(() => { 78 | return ( 79 | imageHotspotOptions.imagePath ? get(rootObject, imageHotspotOptions.imagePath) : rootObject 80 | ) as ImageValue | undefined 81 | }, [rootObject, imageHotspotOptions.imagePath]) 82 | 83 | const displayImage = useMemo(() => { 84 | const builder = imageUrlBuilder(sanityClient).dataset(sanityClient.config().dataset ?? '') 85 | const urlFor = (source: ImageValue) => builder.image(source) 86 | 87 | if (hotspotImage?.asset?._ref) { 88 | const {aspectRatio} = getImageDimensions(hotspotImage.asset._ref) 89 | const width = 1200 90 | const height = Math.round(width / aspectRatio) 91 | const url = urlFor(hotspotImage).width(width).url() 92 | 93 | return {width, height, url} 94 | } 95 | 96 | return null 97 | }, [hotspotImage, sanityClient]) 98 | 99 | const handleHotspotImageClick = useCallback( 100 | async (event: MouseEvent) => { 101 | const {nativeEvent, currentTarget} = event 102 | 103 | // Calculate the x/y percentage of the click position 104 | const x = Number(((nativeEvent.offsetX * 100) / currentTarget.width).toFixed(2)) 105 | const y = Number(((nativeEvent.offsetY * 100) / currentTarget.height).toFixed(2)) 106 | const description = `New Hotspot at ${x}% x ${y}%` 107 | 108 | const initialValues = await resolveInitialValue(schemaType.of[0].type as ObjectSchemaType) 109 | 110 | const newRow: HotspotItem = { 111 | _key: randomKey(12), 112 | _type: schemaType.of[0].name, 113 | ...initialValues, 114 | x, 115 | y, 116 | } 117 | 118 | if (imageHotspotOptions?.descriptionPath) { 119 | newRow[imageHotspotOptions.descriptionPath] = description 120 | } 121 | 122 | onChange(PatchEvent.from([setIfMissing([]), insert([newRow], 'after', [-1])])) 123 | }, 124 | [imageHotspotOptions, onChange, resolveInitialValue, schemaType], 125 | ) 126 | 127 | const handleHotspotMove: FnHotspotMove = useCallback( 128 | (key, x, y) => { 129 | onChange( 130 | PatchEvent.from([ 131 | // Set the `x` value of this array key item 132 | set(x, [{_key: key}, 'x']), 133 | // Set the `y` value of this array key item 134 | set(y, [{_key: key}, 'y']), 135 | ]), 136 | ) 137 | }, 138 | [onChange], 139 | ) 140 | 141 | const hotspotImageRef = useRef(null) 142 | 143 | const [imageRect, setImageRect] = useState() 144 | 145 | useResizeObserver( 146 | hotspotImageRef, 147 | useDebouncedCallback( 148 | (e: ResizeObserverEntry) => setImageRect(e.contentRect), 149 | [setImageRect], 150 | 200, 151 | ), 152 | ) 153 | 154 | return ( 155 | 156 | {displayImage?.url ? ( 157 |
158 | {imageRect && 159 | (value?.length ?? 0) > 0 && 160 | value?.map((spot, index) => ( 161 | 172 | ))} 173 | 174 | 175 | 176 | 185 | 186 | 187 |
188 | ) : ( 189 | 190 | {imageHotspotOptions.imagePath ? ( 191 | <> 192 | No Hotspot image found at path {imageHotspotOptions.imagePath} 193 | 194 | ) : ( 195 | <> 196 | Define a path in this field using to the image field in this document at{' '} 197 | options.hotspotImagePath 198 | 199 | )} 200 | 201 | )} 202 | {imageHotspotOptions.pathRoot && !VALID_ROOT_PATHS.includes(imageHotspotOptions.pathRoot) && ( 203 | 204 | The supplied imageHotspotPathRoot "{imageHotspotOptions.pathRoot}" is not valid, 205 | falling back to "document". Available values are " 206 | {VALID_ROOT_PATHS.join(', ')}". 207 | 208 | )} 209 | {props.renderDefault(props as unknown as ArrayOfObjectsInputProps)} 210 |
211 | ) 212 | } 213 | -------------------------------------------------------------------------------- /src/Spot.tsx: -------------------------------------------------------------------------------- 1 | import {Box, Text, Tooltip} from '@sanity/ui' 2 | import {motion, useMotionValue} from 'framer-motion' 3 | import {get} from 'lodash-es' 4 | import { 5 | ComponentType, 6 | createElement, 7 | CSSProperties, 8 | ReactNode, 9 | useCallback, 10 | useEffect, 11 | useState, 12 | } from 'react' 13 | import {type ObjectSchemaType, type RenderPreviewCallback} from 'sanity' 14 | 15 | import {FnHotspotMove, HotspotItem} from './ImageHotspotArray' 16 | 17 | const dragStyle: CSSProperties = { 18 | width: '1.4rem', 19 | height: '1.4rem', 20 | position: 'absolute', 21 | boxSizing: 'border-box', 22 | top: 0, 23 | left: 0, 24 | margin: '-0.7rem 0 0 -0.7rem', 25 | cursor: 'pointer', 26 | display: 'flex', 27 | justifyContent: 'center', 28 | alignItems: 'center', 29 | borderRadius: '50%', 30 | background: '#000', 31 | color: 'white', 32 | } 33 | 34 | const dragStyleWhileDrag: CSSProperties = { 35 | background: 'rgba(0, 0, 0, 0.1)', 36 | border: '1px solid #fff', 37 | cursor: 'none', 38 | } 39 | 40 | const dragStyleWhileHover: CSSProperties = { 41 | background: 'rgba(0, 0, 0, 0.1)', 42 | border: '1px solid #fff', 43 | } 44 | 45 | const dotStyle: CSSProperties = { 46 | position: 'absolute', 47 | left: '50%', 48 | top: '50%', 49 | height: '0.2rem', 50 | width: '0.2rem', 51 | margin: '-0.1rem 0 0 -0.1rem', 52 | background: '#fff', 53 | visibility: 'hidden', 54 | borderRadius: '50%', 55 | // make sure pointer events only run on the parent 56 | pointerEvents: 'none', 57 | } 58 | 59 | const dotStyleWhileActive: CSSProperties = { 60 | visibility: 'visible', 61 | } 62 | 63 | const labelStyle: CSSProperties = { 64 | color: 'white', 65 | fontSize: '0.7rem', 66 | fontWeight: 600, 67 | lineHeight: '1', 68 | } 69 | 70 | const labelStyleWhileActive: CSSProperties = { 71 | visibility: 'hidden', 72 | } 73 | 74 | const round = (num: number) => Math.round(num * 100) / 100 75 | 76 | export interface HotspotTooltipProps { 77 | value: HotspotItem 78 | schemaType: ObjectSchemaType 79 | renderPreview: RenderPreviewCallback 80 | } 81 | 82 | interface HotspotProps { 83 | value: HotspotItem 84 | bounds: DOMRectReadOnly 85 | update: FnHotspotMove 86 | hotspotDescriptionPath?: string 87 | tooltip?: ComponentType> 88 | index: number 89 | schemaType: ObjectSchemaType 90 | renderPreview: RenderPreviewCallback 91 | } 92 | 93 | export default function Spot({ 94 | value, 95 | bounds, 96 | update, 97 | hotspotDescriptionPath, 98 | tooltip, 99 | index, 100 | schemaType, 101 | renderPreview, 102 | }: HotspotProps): ReactNode { 103 | const [isDragging, setIsDragging] = useState(false) 104 | const [isHovering, setIsHovering] = useState(false) 105 | 106 | // x/y are stored as % but need to be converted to px 107 | const x = useMotionValue(round(bounds.width * (value.x / 100))) 108 | const y = useMotionValue(round(bounds.height * (value.y / 100))) 109 | 110 | /** 111 | * update x/y if the bounds change when resizing the window 112 | */ 113 | useEffect(() => { 114 | x.set(round(bounds.width * (value.x / 100))) 115 | y.set(round(bounds.height * (value.y / 100))) 116 | }, [x, y, value, bounds]) 117 | 118 | const handleDragEnd = useCallback(() => { 119 | setIsDragging(false) 120 | 121 | // get current values for x/y in px 122 | const currentX = x.get() 123 | const currentY = y.get() 124 | 125 | // Which we need to convert back to `%` to patch the document 126 | const newX = round((currentX * 100) / bounds.width) 127 | const newY = round((currentY * 100) / bounds.height) 128 | 129 | // Don't go below 0 or above 100 130 | const safeX = Math.max(0, Math.min(100, newX)) 131 | const safeY = Math.max(0, Math.min(100, newY)) 132 | 133 | update(value._key, safeX, safeY) 134 | }, [x, y, value, update, bounds]) 135 | const handleDragStart = useCallback(() => setIsDragging(true), []) 136 | 137 | const handleHoverStart = useCallback(() => setIsHovering(true), []) 138 | const handleHoverEnd = useCallback(() => setIsHovering(false), []) 139 | 140 | if (!x || !y) { 141 | return null 142 | } 143 | 144 | return ( 145 | 162 | 171 | 172 | {hotspotDescriptionPath 173 | ? (get(value, hotspotDescriptionPath) as string) 174 | : `${value.x}% x ${value.y}%`} 175 | 176 | 177 | ) 178 | } 179 | > 180 |
181 | {/* Dot */} 182 | 188 | {/* Label */} 189 |
195 | {index + 1} 196 |
197 |
198 |
199 |
200 | ) 201 | } 202 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export {type HotspotItem, ImageHotspotArray} from './ImageHotspotArray' 2 | export {imageHotspotArrayPlugin, type ImageHotspotOptions} from './plugin' 3 | export {type HotspotTooltipProps} from './Spot' 4 | -------------------------------------------------------------------------------- /src/plugin.tsx: -------------------------------------------------------------------------------- 1 | import {ComponentType} from 'react' 2 | import {type ArrayOfObjectsInputProps, definePlugin} from 'sanity' 3 | 4 | import {type HotspotItem, ImageHotspotArray} from './ImageHotspotArray' 5 | import {type HotspotTooltipProps} from './Spot' 6 | 7 | export interface ImageHotspotOptions { 8 | pathRoot?: 'document' | 'parent' 9 | imagePath: string 10 | descriptionPath?: string 11 | tooltip?: ComponentType> 12 | } 13 | 14 | declare module '@sanity/types' { 15 | export interface ArrayOptions { 16 | imageHotspot?: ImageHotspotOptions 17 | } 18 | } 19 | 20 | export const imageHotspotArrayPlugin = definePlugin({ 21 | name: 'image-hotspot-array', 22 | form: { 23 | components: { 24 | input: (props) => { 25 | if (props.schemaType.jsonType === 'array' && props.schemaType.options?.imageHotspot) { 26 | const imageHotspotOptions = props.schemaType.options?.imageHotspot 27 | if (imageHotspotOptions) { 28 | return ( 29 | )} 31 | imageHotspotOptions={imageHotspotOptions} 32 | /> 33 | ) 34 | } 35 | } 36 | return props.renderDefault(props) 37 | }, 38 | }, 39 | }, 40 | }) 41 | -------------------------------------------------------------------------------- /src/useDebouncedCallback.ts: -------------------------------------------------------------------------------- 1 | // copied from react-hookz/web 2 | // https://github.com/react-hookz/web/blob/579a445fcc9f4f4bb5b9d5e670b2e57448b4ee50/src/useDebouncedCallback/index.ts 3 | import {type DependencyList, useEffect, useMemo, useRef} from 'react' 4 | 5 | /** 6 | * Run effect only when component is unmounted. 7 | * 8 | * @param effect Effector to run on unmount 9 | */ 10 | export function useUnmountEffect(effect: CallableFunction): void { 11 | const effectRef = useRef(effect) 12 | effectRef.current = effect 13 | useEffect(() => () => effectRef.current(), []) 14 | } 15 | 16 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 17 | export type DebouncedFunction any> = ( 18 | this: ThisParameterType, 19 | ...args: Parameters 20 | ) => void 21 | 22 | /** 23 | * Makes passed function debounced, otherwise acts like `useCallback`. 24 | * 25 | * @param callback Function that will be debounced. 26 | * @param deps Dependencies list when to update callback. It also replaces invoked 27 | * callback for scheduled debounced invocations. 28 | * @param delay Debounce delay. 29 | * @param maxWait The maximum time `callback` is allowed to be delayed before 30 | * it's invoked. 0 means no max wait. 31 | */ 32 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 33 | export function useDebouncedCallback any>( 34 | callback: Fn, 35 | deps: DependencyList, 36 | delay: number, 37 | maxWait = 0, 38 | ): DebouncedFunction { 39 | const timeout = useRef>() 40 | const waitTimeout = useRef>() 41 | const cb = useRef(callback) 42 | const lastCall = useRef<{args: Parameters; this: ThisParameterType}>() 43 | 44 | const clear = () => { 45 | if (timeout.current) { 46 | clearTimeout(timeout.current) 47 | timeout.current = undefined 48 | } 49 | 50 | if (waitTimeout.current) { 51 | clearTimeout(waitTimeout.current) 52 | waitTimeout.current = undefined 53 | } 54 | } 55 | 56 | // Cancel scheduled execution on unmount 57 | useUnmountEffect(clear) 58 | 59 | useEffect(() => { 60 | cb.current = callback 61 | // eslint-disable-next-line react-hooks/exhaustive-deps 62 | }, deps) 63 | 64 | return useMemo(() => { 65 | const execute = () => { 66 | clear() 67 | 68 | // Barely possible to test this line 69 | /* istanbul ignore next */ 70 | if (!lastCall.current) return 71 | 72 | const context = lastCall.current 73 | lastCall.current = undefined 74 | 75 | cb.current.apply(context.this, context.args) 76 | } 77 | 78 | const wrapped = function (this, ...args) { 79 | if (timeout.current) { 80 | clearTimeout(timeout.current) 81 | } 82 | 83 | lastCall.current = {args, this: this} 84 | 85 | // Plan regular execution 86 | timeout.current = setTimeout(execute, delay) 87 | 88 | // Plan maxWait execution if required 89 | if (maxWait > 0 && !waitTimeout.current) { 90 | waitTimeout.current = setTimeout(execute, maxWait) 91 | } 92 | } as DebouncedFunction 93 | 94 | Object.defineProperties(wrapped, { 95 | length: {value: callback.length}, 96 | name: {value: `${callback.name || 'anonymous'}__debounced__${delay}`}, 97 | }) 98 | 99 | return wrapped 100 | // eslint-disable-next-line react-hooks/exhaustive-deps 101 | }, [delay, maxWait, ...deps]) 102 | } 103 | -------------------------------------------------------------------------------- /src/useResizeObserver.ts: -------------------------------------------------------------------------------- 1 | // copied from react-hookz/web 2 | // https://github.com/react-hookz/web/blob/579a445fcc9f4f4bb5b9d5e670b2e57448b4ee50/src/useResizeObserver/index.ts 3 | import {type RefObject, useEffect, useRef} from 'react' 4 | 5 | const isBrowser = 6 | typeof window !== 'undefined' && 7 | typeof navigator !== 'undefined' && 8 | typeof document !== 'undefined' 9 | 10 | export type UseResizeObserverCallback = (entry: ResizeObserverEntry) => void 11 | 12 | type ResizeObserverSingleton = { 13 | observer: ResizeObserver 14 | subscribe: (target: Element, callback: UseResizeObserverCallback) => void 15 | unsubscribe: (target: Element, callback: UseResizeObserverCallback) => void 16 | } 17 | 18 | let observerSingleton: ResizeObserverSingleton 19 | 20 | function getResizeObserver(): ResizeObserverSingleton | undefined { 21 | if (!isBrowser) return undefined 22 | 23 | if (observerSingleton) return observerSingleton 24 | 25 | const callbacks = new Map>() 26 | 27 | const observer = new ResizeObserver((entries) => { 28 | for (const entry of entries) 29 | callbacks.get(entry.target)?.forEach((cb) => 30 | setTimeout(() => { 31 | cb(entry) 32 | }, 0), 33 | ) 34 | }) 35 | 36 | observerSingleton = { 37 | observer, 38 | subscribe(target, callback) { 39 | let cbs = callbacks.get(target) 40 | 41 | if (!cbs) { 42 | // If target has no observers yet - register it 43 | cbs = new Set() 44 | callbacks.set(target, cbs) 45 | observer.observe(target) 46 | } 47 | 48 | // As Set is duplicate-safe - simply add callback on each call 49 | cbs.add(callback) 50 | }, 51 | unsubscribe(target, callback) { 52 | const cbs = callbacks.get(target) 53 | 54 | // Else branch should never occur in case of normal execution 55 | // because callbacks map is hidden in closure - it is impossible to 56 | // simulate situation with non-existent `cbs` Set 57 | /* istanbul ignore else */ 58 | if (cbs) { 59 | // Remove current observer 60 | cbs.delete(callback) 61 | 62 | if (cbs.size === 0) { 63 | // If no observers left unregister target completely 64 | callbacks.delete(target) 65 | observer.unobserve(target) 66 | } 67 | } 68 | }, 69 | } 70 | 71 | return observerSingleton 72 | } 73 | 74 | /** 75 | * Invokes a callback whenever ResizeObserver detects a change to target's size. 76 | * 77 | * @param target React reference or Element to track. 78 | * @param callback Callback that will be invoked on resize. 79 | * @param enabled Whether resize observer is enabled or not. 80 | */ 81 | export function useResizeObserver( 82 | target: RefObject | T | null, 83 | callback: UseResizeObserverCallback, 84 | enabled = true, 85 | ): void { 86 | const ro = enabled && getResizeObserver() 87 | const cb = useRef(callback) 88 | cb.current = callback 89 | 90 | const tgt = target && 'current' in target ? target.current : target 91 | 92 | useEffect(() => { 93 | // This secondary target resolve required for case when we receive ref object, which, most 94 | // likely, contains null during render stage, but already populated with element during 95 | // effect stage. 96 | 97 | const _tgt = target && 'current' in target ? target.current : target 98 | 99 | if (!ro || !_tgt) return undefined 100 | 101 | // As unsubscription in internals of our ResizeObserver abstraction can 102 | // happen a bit later than effect cleanup invocation - we need a marker, 103 | // that this handler should not be invoked anymore 104 | let subscribed = true 105 | 106 | const handler: UseResizeObserverCallback = (...args) => { 107 | // It is reinsurance for the highly asynchronous invocations, almost 108 | // impossible to achieve in tests, thus excluding from LOC 109 | /* istanbul ignore else */ 110 | if (subscribed) { 111 | cb.current(...args) 112 | } 113 | } 114 | 115 | ro.subscribe(_tgt, handler) 116 | 117 | return () => { 118 | subscribed = false 119 | ro.unsubscribe(_tgt, handler) 120 | } 121 | // eslint-disable-next-line react-hooks/exhaustive-deps 122 | }, [tgt, ro]) 123 | } 124 | -------------------------------------------------------------------------------- /tsconfig.dist.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.settings", 3 | "include": ["./src"], 4 | "exclude": [ 5 | "./src/**/__fixtures__", 6 | "./src/**/__mocks__", 7 | "./src/**/*.test.ts", 8 | "./src/**/*.test.tsx" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.settings", 3 | "include": ["./src", "./package.config.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": ".", 4 | "outDir": "./dist", 5 | 6 | "target": "esnext", 7 | "jsx": "preserve", 8 | "module": "preserve", 9 | "moduleResolution": "bundler", 10 | "esModuleInterop": true, 11 | "resolveJsonModule": true, 12 | "moduleDetection": "force", 13 | "strict": true, 14 | "allowSyntheticDefaultImports": true, 15 | "skipLibCheck": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "isolatedModules": true, 18 | 19 | // Don't emit by default, pkg-utils will ignore this when generating .d.ts files 20 | "noEmit": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /v2-incompatible.js: -------------------------------------------------------------------------------- 1 | const {showIncompatiblePluginDialog} = require('@sanity/incompatible-plugin') 2 | const {name, version, sanityExchangeUrl} = require('./package.json') 3 | 4 | export default showIncompatiblePluginDialog({ 5 | name: name, 6 | versions: { 7 | v3: version, 8 | v2: '^0.0.10', 9 | }, 10 | sanityExchangeUrl, 11 | }) 12 | --------------------------------------------------------------------------------