sanity-plugin-hotspot-array 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.babelrc ADDED
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "sanipack/babel"
3
+ }
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2021 Simeon Griggs
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.
package/README.md ADDED
@@ -0,0 +1,121 @@
1
+ # sanity-plugin-hotspot-array
2
+
3
+ A configurable Custom Input for Arrays that will add and update items by clicking on an Image
4
+
5
+ ## Installation
6
+
7
+ ```
8
+ sanity install hotspot-array
9
+ ```
10
+
11
+ ![2021-12-22 15 07 58](https://user-images.githubusercontent.com/9684022/147113578-d49d747a-4904-45b4-b4e0-409b33832226.gif)
12
+
13
+ ## Setup
14
+
15
+ Import the `HotspotArray` component from this package, into your schema. And insert it as the `inputComponent` of an `array` field.
16
+
17
+ ```js
18
+ import HotspotArray from 'sanity-plugin-hotspot-array'
19
+
20
+ export default {
21
+ name: `product`,
22
+ title: `Product`,
23
+ type: `document`,
24
+ fields: [
25
+ {
26
+ name: `hotspots`,
27
+ type: `array`,
28
+ inputComponent: HotspotArray,
29
+ of: [
30
+ // see `Spot object` setup below
31
+ ],
32
+ options: {
33
+ // see `Image and description path` setup below
34
+ hotspotImagePath: `featureImage`
35
+ hotspotDescriptionPath: `details`
36
+ }
37
+ }
38
+ // ...all your other fields
39
+ ]
40
+ }
41
+ ```
42
+
43
+ The plugin makes a number of assumptions to add and update data in the array. Including:
44
+
45
+ - The `array` field is an array of objects
46
+ - The object contains two number fields named `x` and `y`
47
+ - You'll want to save those values as % from the top left of the image
48
+ - The same document contains the image you want to add hotspots to
49
+
50
+ ### Image and description paths
51
+
52
+ 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.
53
+
54
+ 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:
55
+
56
+ ```js
57
+ options: {
58
+ hotspotImagePath: `featureImage`
59
+ }
60
+ ```
61
+
62
+ 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. Add a path **relative to the spot object** for this field.
63
+
64
+ ```js
65
+ options: {
66
+ hotspotDescriptionPath: `details`
67
+ }
68
+ ```
69
+
70
+ ### Spot object
71
+
72
+ Here's an example object schema complete with initial values, validation, fieldsets and a styled preview.
73
+
74
+ ```js
75
+ {
76
+ name: 'spot',
77
+ type: 'object',
78
+ fieldsets: [{name: 'position', options: {columns: 2}}],
79
+ fields: [
80
+ {name: 'details', type: 'text', rows: 2},
81
+ {
82
+ name: 'x',
83
+ type: 'number',
84
+ readOnly: true,
85
+ fieldset: 'position',
86
+ initialValue: 50,
87
+ validation: (Rule) => Rule.required().min(0).max(100),
88
+ },
89
+ {
90
+ name: 'y',
91
+ type: 'number',
92
+ readOnly: true,
93
+ fieldset: 'position',
94
+ initialValue: 50,
95
+ validation: (Rule) => Rule.required().min(0).max(100),
96
+ },
97
+ ],
98
+ preview: {
99
+ select: {
100
+ title: 'description',
101
+ x: 'x',
102
+ y: 'y',
103
+ },
104
+ prepare({title, x, y}) {
105
+ return {
106
+ title,
107
+ subtitle: x && y ? `${x}% x ${y}%` : `No position set`,
108
+ }
109
+ },
110
+ },
111
+ }
112
+ ```
113
+
114
+ ## Future cool ideas I just haven't added (yet?)
115
+
116
+ - An `options` key to define a custom preview component for the Spot's `<Tooltip />`
117
+
118
+ ## License
119
+
120
+ MIT © Simeon Griggs
121
+ See LICENSE
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.default = Feedback;
7
+
8
+ var _react = _interopRequireDefault(require("react"));
9
+
10
+ var _ui = require("@sanity/ui");
11
+
12
+ function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
13
+
14
+ function Feedback(_ref) {
15
+ var children = _ref.children;
16
+ return /*#__PURE__*/_react.default.createElement(_ui.Card, {
17
+ padding: 4,
18
+ radius: 2,
19
+ shadow: 1,
20
+ tone: "caution"
21
+ }, /*#__PURE__*/_react.default.createElement(_ui.Text, null, children));
22
+ }
23
+ //# sourceMappingURL=Feedback.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/Feedback.tsx"],"names":["Feedback","children"],"mappings":";;;;;;;AAAA;;AACA;;;;AAEe,SAASA,QAAT,OAA8B;AAAA,MAAXC,QAAW,QAAXA,QAAW;AAC3C,sBACE,6BAAC,QAAD;AAAM,IAAA,OAAO,EAAE,CAAf;AAAkB,IAAA,MAAM,EAAE,CAA1B;AAA6B,IAAA,MAAM,EAAE,CAArC;AAAwC,IAAA,IAAI,EAAC;AAA7C,kBACE,6BAAC,QAAD,QAAOA,QAAP,CADF,CADF;AAKD","sourcesContent":["import React from 'react'\nimport {Card, Text} from '@sanity/ui'\n\nexport default function Feedback({children}) {\n return (\n <Card padding={4} radius={2} shadow={1} tone=\"caution\">\n <Text>{children}</Text>\n </Card>\n )\n}\n"],"file":"Feedback.js"}
@@ -0,0 +1,163 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.default = void 0;
7
+
8
+ var _client = _interopRequireDefault(require("part:@sanity/base/client"));
9
+
10
+ var _formBuilder = require("part:@sanity/form-builder");
11
+
12
+ var _react = _interopRequireDefault(require("react"));
13
+
14
+ var _ui = require("@sanity/ui");
15
+
16
+ var _FormBuilderInput = require("@sanity/form-builder/lib/FormBuilderInput");
17
+
18
+ var _content = require("@sanity/util/content");
19
+
20
+ var _PatchEvent = require("@sanity/form-builder/PatchEvent");
21
+
22
+ var _imageUrl = _interopRequireDefault(require("@sanity/image-url"));
23
+
24
+ var _assetUtils = require("@sanity/asset-utils");
25
+
26
+ var _get = _interopRequireDefault(require("lodash/get"));
27
+
28
+ var _Spot = _interopRequireDefault(require("./Spot"));
29
+
30
+ var _useUnsetInputComponent = require("./useUnsetInputComponent");
31
+
32
+ var _Feedback = _interopRequireDefault(require("./Feedback"));
33
+
34
+ function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
35
+
36
+ function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
37
+
38
+ var builder = (0, _imageUrl.default)(_client.default);
39
+
40
+ var urlFor = source => builder.image(source);
41
+
42
+ var imageStyle = {
43
+ width: "100%",
44
+ height: "auto"
45
+ };
46
+
47
+ var HotspotArray = /*#__PURE__*/_react.default.forwardRef((props, ref) => {
48
+ var _type$options4, _type$options5;
49
+
50
+ var type = props.type,
51
+ value = props.value,
52
+ onChange = props.onChange,
53
+ sanityDocument = props.document; // Attempt prevention of infinite loop in <FormBuilderInput />
54
+ // Re-renders can still occur if this Component is used again in a nested field
55
+
56
+ var typeWithoutInputComponent = (0, _useUnsetInputComponent.useUnsetInputComponent)(type, type === null || type === void 0 ? void 0 : type.inputComponent); // Finding the image from the document,
57
+ // using the path from the hotspot's `options` field
58
+
59
+ var displayImage = _react.default.useMemo(() => {
60
+ var _type$options, _hotspotImage$asset;
61
+
62
+ var hotspotImage = (0, _get.default)(sanityDocument, type === null || type === void 0 ? void 0 : (_type$options = type.options) === null || _type$options === void 0 ? void 0 : _type$options.hotspotImagePath);
63
+
64
+ if (hotspotImage !== null && hotspotImage !== void 0 && (_hotspotImage$asset = hotspotImage.asset) !== null && _hotspotImage$asset !== void 0 && _hotspotImage$asset._ref) {
65
+ var _getImageDimensions = (0, _assetUtils.getImageDimensions)(hotspotImage.asset._ref),
66
+ aspectRatio = _getImageDimensions.aspectRatio;
67
+
68
+ var width = 600;
69
+ var height = Math.round(width / aspectRatio);
70
+ var url = urlFor(hotspotImage).width(width).url();
71
+ return {
72
+ width,
73
+ height,
74
+ url
75
+ };
76
+ }
77
+
78
+ return null;
79
+ }, [sanityDocument, type]);
80
+
81
+ var handleHotspotImageClick = _react.default.useCallback(event => {
82
+ var _type$options2;
83
+
84
+ var nativeEvent = event.nativeEvent; // Calculate the x/y percentage of the click position
85
+
86
+ var x = Number((nativeEvent.offsetX * 100 / nativeEvent.srcElement.width).toFixed(2));
87
+ var y = Number((nativeEvent.offsetY * 100 / nativeEvent.srcElement.height).toFixed(2));
88
+ var description = "New Hotspot at ".concat(x, "% x ").concat(y, "%");
89
+ var newRow = {
90
+ _key: (0, _content.randomKey)(12),
91
+ _type: "spot",
92
+ x,
93
+ y
94
+ };
95
+
96
+ if (type !== null && type !== void 0 && (_type$options2 = type.options) !== null && _type$options2 !== void 0 && _type$options2.hotspotDescriptionPath) {
97
+ console.log(type.options.hotspotDescriptionPath);
98
+ newRow[type.options.hotspotDescriptionPath] = description;
99
+ }
100
+
101
+ onChange(_PatchEvent.PatchEvent.from((0, _PatchEvent.setIfMissing)([]), (0, _PatchEvent.insert)([newRow], 'after', [-1])));
102
+ }, []);
103
+
104
+ var handleHotspotMove = _react.default.useCallback((key, x, y) => {
105
+ if (!Number(x) || !Number(y)) {
106
+ console.warn("Missing or non-number X or Y", {
107
+ x,
108
+ y
109
+ });
110
+ return;
111
+ }
112
+
113
+ onChange(_PatchEvent.PatchEvent.from( // Set the `x` value of this array key item
114
+ (0, _PatchEvent.set)(x, [{
115
+ _key: key
116
+ }, 'x']), // Set the `y` value of this array key item
117
+ (0, _PatchEvent.set)(y, [{
118
+ _key: key
119
+ }, 'y'])));
120
+ }, [value]);
121
+
122
+ var hotspotImageRef = _react.default.useRef(null);
123
+
124
+ return /*#__PURE__*/_react.default.createElement(_ui.Stack, {
125
+ space: [2, 2, 3]
126
+ }, displayImage !== null && displayImage !== void 0 && displayImage.url ? /*#__PURE__*/_react.default.createElement("div", {
127
+ style: {
128
+ position: "relative"
129
+ }
130
+ }, (value === null || value === void 0 ? void 0 : value.length) > 0 && value.map(spot => {
131
+ var _type$options3;
132
+
133
+ return /*#__PURE__*/_react.default.createElement(_Spot.default, {
134
+ key: spot._key,
135
+ spot: spot,
136
+ bounds: hotspotImageRef,
137
+ update: handleHotspotMove,
138
+ hotspotDescriptionPath: type === null || type === void 0 ? void 0 : (_type$options3 = type.options) === null || _type$options3 === void 0 ? void 0 : _type$options3.hotspotDescriptionPath
139
+ });
140
+ }), /*#__PURE__*/_react.default.createElement(_ui.Card, {
141
+ __unstable_checkered: true,
142
+ shadow: 1
143
+ }, /*#__PURE__*/_react.default.createElement(_ui.Flex, {
144
+ align: "center",
145
+ justify: "center"
146
+ }, /*#__PURE__*/_react.default.createElement("img", {
147
+ ref: hotspotImageRef,
148
+ src: displayImage.url,
149
+ width: displayImage.width,
150
+ height: displayImage.height,
151
+ alt: "",
152
+ style: imageStyle,
153
+ onClick: handleHotspotImageClick
154
+ })))) : /*#__PURE__*/_react.default.createElement(_Feedback.default, null, type !== null && type !== void 0 && (_type$options4 = type.options) !== null && _type$options4 !== void 0 && _type$options4.hotspotImagePath ? /*#__PURE__*/_react.default.createElement(_react.default.Fragment, null, "No Hotspot image found at path ", /*#__PURE__*/_react.default.createElement("code", null, type === null || type === void 0 ? void 0 : (_type$options5 = type.options) === null || _type$options5 === void 0 ? void 0 : _type$options5.hotspotImagePath)) : /*#__PURE__*/_react.default.createElement(_react.default.Fragment, null, "Define a path in this field using to the image field in this document at ", /*#__PURE__*/_react.default.createElement("code", null, "options.hotspotImagePath"))), /*#__PURE__*/_react.default.createElement(_FormBuilderInput.FormBuilderInput, _extends({}, props, {
155
+ type: typeWithoutInputComponent,
156
+ ref: ref
157
+ })));
158
+ });
159
+
160
+ var _default = (0, _formBuilder.withDocument)(HotspotArray);
161
+
162
+ exports.default = _default;
163
+ //# sourceMappingURL=HotspotArray.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/HotspotArray.tsx"],"names":["builder","sanityClient","urlFor","source","image","imageStyle","width","height","HotspotArray","React","forwardRef","props","ref","type","value","onChange","sanityDocument","document","typeWithoutInputComponent","inputComponent","displayImage","useMemo","hotspotImage","options","hotspotImagePath","asset","_ref","aspectRatio","Math","round","url","handleHotspotImageClick","useCallback","event","nativeEvent","x","Number","offsetX","srcElement","toFixed","y","offsetY","description","newRow","_key","_type","hotspotDescriptionPath","console","log","PatchEvent","from","handleHotspotMove","key","warn","hotspotImageRef","useRef","position","length","map","spot"],"mappings":"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","sourcesContent":["/* eslint-disable react/display-name */\n\n// @ts-ignore\nimport sanityClient from 'part:@sanity/base/client'\n// @ts-ignore\nimport {withDocument} from 'part:@sanity/form-builder'\n\nimport React from 'react'\nimport {Card, Flex, Stack, Text} from '@sanity/ui'\nimport {FormBuilderInput} from '@sanity/form-builder/lib/FormBuilderInput'\nimport {randomKey} from '@sanity/util/content'\nimport {PatchEvent, setIfMissing, set, insert} from '@sanity/form-builder/PatchEvent'\nimport imageUrlBuilder from '@sanity/image-url'\nimport {getImageDimensions} from '@sanity/asset-utils'\nimport get from 'lodash/get'\n\nimport Spot from './Spot'\nimport { useUnsetInputComponent } from './useUnsetInputComponent'\nimport Feedback from './Feedback'\n\nconst builder = imageUrlBuilder(sanityClient)\nconst urlFor = (source) => builder.image(source)\nconst imageStyle = {width: `100%`, height: `auto`}\n\n\nconst HotspotArray = React.forwardRef((props, ref) => {\n const {type, value, onChange, document: sanityDocument} = props\n\n // Attempt prevention of infinite loop in <FormBuilderInput />\n // Re-renders can still occur if this Component is used again in a nested field\n const typeWithoutInputComponent = useUnsetInputComponent(type, type?.inputComponent)\n\n // Finding the image from the document,\n // using the path from the hotspot's `options` field\n const displayImage = React.useMemo(() => {\n const hotspotImage = get(sanityDocument, type?.options?.hotspotImagePath)\n\n if (hotspotImage?.asset?._ref) {\n const {aspectRatio} = getImageDimensions(hotspotImage.asset._ref)\n const width = 600\n const height = Math.round(width / aspectRatio)\n const url = urlFor(hotspotImage).width(width).url()\n\n return {width, height, url}\n }\n\n return null\n }, [sanityDocument, type])\n\n const handleHotspotImageClick = React.useCallback((event) => {\n const {nativeEvent} = event\n\n // Calculate the x/y percentage of the click position\n const x = Number(((nativeEvent.offsetX * 100) / nativeEvent.srcElement.width).toFixed(2))\n const y = Number(((nativeEvent.offsetY * 100) / nativeEvent.srcElement.height).toFixed(2))\n const description = `New Hotspot at ${x}% x ${y}%`\n\n const newRow = {\n _key: randomKey(12),\n _type: `spot`,\n x,\n y,\n }\n\n if (type?.options?.hotspotDescriptionPath) {\n console.log(type.options.hotspotDescriptionPath);\n newRow[type.options.hotspotDescriptionPath] = description\n }\n\n onChange(PatchEvent.from(setIfMissing([]), insert([newRow], 'after', [-1])))\n }, [])\n\n const handleHotspotMove = React.useCallback(\n (key, x, y) => {\n if (!Number(x) || !Number(y)) {\n console.warn(`Missing or non-number X or Y`, {x, y})\n return\n }\n\n onChange(\n PatchEvent.from(\n // Set the `x` value of this array key item\n set(x, [{_key: key}, 'x']),\n // Set the `y` value of this array key item\n set(y, [{_key: key}, 'y'])\n )\n )\n },\n [value]\n )\n\n const hotspotImageRef = React.useRef(null)\n\n return (\n <Stack space={[2,2,3]}>\n {displayImage?.url ? (\n <div style={{position: `relative`}}>\n {value?.length > 0 &&\n value.map((spot) => (\n <Spot\n key={spot._key}\n spot={spot}\n bounds={hotspotImageRef}\n update={handleHotspotMove}\n hotspotDescriptionPath={type?.options?.hotspotDescriptionPath}\n />\n ))}\n\n <Card __unstable_checkered shadow={1}>\n <Flex align=\"center\" justify=\"center\">\n <img\n ref={hotspotImageRef}\n src={displayImage.url}\n width={displayImage.width}\n height={displayImage.height}\n alt=\"\"\n style={imageStyle}\n onClick={handleHotspotImageClick}\n />\n </Flex>\n </Card>\n </div>\n ) : (\n <Feedback>\n {type?.options?.hotspotImagePath \n ? <>No Hotspot image found at path <code>{type?.options?.hotspotImagePath}</code></> \n : <>Define a path in this field using to the image field in this document at <code>options.hotspotImagePath</code></>\n }\n </Feedback>\n )}\n <FormBuilderInput {...props} type={typeWithoutInputComponent} ref={ref} />\n </Stack>\n )\n})\n\nexport default withDocument(HotspotArray)\n"],"file":"HotspotArray.js"}
package/lib/Spot.js ADDED
@@ -0,0 +1,159 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.default = Spot;
7
+
8
+ var _react = _interopRequireDefault(require("react"));
9
+
10
+ var _ui = require("@sanity/ui");
11
+
12
+ var _framerMotion = require("framer-motion");
13
+
14
+ var _get = _interopRequireDefault(require("lodash/get"));
15
+
16
+ function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
17
+
18
+ function _slicedToArray(arr, i) { return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _unsupportedIterableToArray(arr, i) || _nonIterableRest(); }
19
+
20
+ function _nonIterableRest() { throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); }
21
+
22
+ function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); }
23
+
24
+ function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) { arr2[i] = arr[i]; } return arr2; }
25
+
26
+ function _iterableToArrayLimit(arr, i) { var _i = arr == null ? null : typeof Symbol !== "undefined" && arr[Symbol.iterator] || arr["@@iterator"]; if (_i == null) return; var _arr = []; var _n = true; var _d = false; var _s, _e; try { for (_i = _i.call(arr); !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i["return"] != null) _i["return"](); } finally { if (_d) throw _e; } } return _arr; }
27
+
28
+ function _arrayWithHoles(arr) { if (Array.isArray(arr)) return arr; }
29
+
30
+ function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
31
+
32
+ function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
33
+
34
+ function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
35
+
36
+ var dragStyle = {
37
+ width: "1rem",
38
+ height: "1rem",
39
+ position: "absolute"
40
+ };
41
+
42
+ var dotStyle = _objectSpread(_objectSpread({}, dragStyle), {}, {
43
+ borderRadius: "50%",
44
+ // make sure pointer events only run on the parent
45
+ pointerEvents: "none"
46
+ });
47
+
48
+ var round = num => Math.round(num * 100) / 100;
49
+
50
+ function Spot(_ref) {
51
+ var spot = _ref.spot,
52
+ _ref$bounds = _ref.bounds,
53
+ bounds = _ref$bounds === void 0 ? undefined : _ref$bounds,
54
+ update = _ref.update,
55
+ _ref$hotspotDescripti = _ref.hotspotDescriptionPath,
56
+ hotspotDescriptionPath = _ref$hotspotDescripti === void 0 ? "" : _ref$hotspotDescripti;
57
+
58
+ // x/y are stored as % but need to be displayed as px
59
+ var _React$useState = _react.default.useState({
60
+ x: 0,
61
+ y: 0
62
+ }),
63
+ _React$useState2 = _slicedToArray(_React$useState, 2),
64
+ _React$useState2$ = _React$useState2[0],
65
+ x = _React$useState2$.x,
66
+ y = _React$useState2$.y,
67
+ setXY = _React$useState2[1];
68
+
69
+ var _React$useState3 = _react.default.useState({
70
+ width: 0,
71
+ height: 0
72
+ }),
73
+ _React$useState4 = _slicedToArray(_React$useState3, 2),
74
+ rect = _React$useState4[0],
75
+ setRect = _React$useState4[1];
76
+
77
+ var _React$useState5 = _react.default.useState(false),
78
+ _React$useState6 = _slicedToArray(_React$useState5, 2),
79
+ isDragging = _React$useState6[0],
80
+ setIsDragging = _React$useState6[1];
81
+
82
+ _react.default.useEffect(() => {
83
+ var _bounds$current;
84
+
85
+ var clientRect = bounds === null || bounds === void 0 ? void 0 : (_bounds$current = bounds.current) === null || _bounds$current === void 0 ? void 0 : _bounds$current.getBoundingClientRect();
86
+
87
+ if (clientRect) {
88
+ // So convert % to px once we know the height/width of the image
89
+ setXY({
90
+ x: round(clientRect.width * (spot.x / 100)),
91
+ y: round(clientRect.height * (spot.y / 100))
92
+ });
93
+ setRect(clientRect);
94
+ }
95
+ }, [bounds, bounds.current]);
96
+
97
+ var handleDragEnd = _react.default.useCallback(event => {
98
+ setIsDragging(false); // I don't know why, but framer-motion doesn't give you the actual transform values
99
+ // So we have to regex the `px` values off the inline styles
100
+
101
+ var _event$srcElement$sty = event.srcElement.style.transform.split(" ").map(v => {
102
+ return v ? v.match(/\(([^)]+)\)/).pop().replace("px", "") : null;
103
+ }),
104
+ _event$srcElement$sty2 = _slicedToArray(_event$srcElement$sty, 2),
105
+ currentX = _event$srcElement$sty2[0],
106
+ currentY = _event$srcElement$sty2[1];
107
+
108
+ if (!Number(currentX) || !Number(currentY)) {
109
+ return console.warn("Missing or non-number X or Y", {
110
+ currentX,
111
+ currentY
112
+ }, event.srcElement);
113
+ } // Which we need to convert back to `%` to patch the document
114
+
115
+
116
+ var newX = round(currentX * 100 / rect.width);
117
+ var newY = round(currentY * 100 / rect.height); // Don't go below 0 or above 100
118
+
119
+ var safeX = Math.max(0, Math.min(100, newX));
120
+ var safeY = Math.max(0, Math.min(100, newY));
121
+ update(spot._key, safeX, safeY);
122
+ }, [spot]);
123
+
124
+ if (!x || !y) {
125
+ return null;
126
+ }
127
+
128
+ return /*#__PURE__*/_react.default.createElement(_ui.Tooltip, {
129
+ key: spot._key,
130
+ disabled: isDragging,
131
+ boundaryElement: bounds.current,
132
+ portal: true,
133
+ content: /*#__PURE__*/_react.default.createElement(_ui.Box, {
134
+ padding: 2,
135
+ style: {
136
+ maxWidth: 200,
137
+ pointerEvents: "none"
138
+ }
139
+ }, /*#__PURE__*/_react.default.createElement(_ui.Text, {
140
+ textOverflow: "ellipsis"
141
+ }, hotspotDescriptionPath ? (0, _get.default)(spot, hotspotDescriptionPath) : "".concat(spot.x, "% x ").concat(spot.y, "%")))
142
+ }, /*#__PURE__*/_react.default.createElement(_framerMotion.motion.div, {
143
+ drag: true,
144
+ dragConstraints: bounds,
145
+ dragMomentum: false,
146
+ initial: {
147
+ x,
148
+ y
149
+ },
150
+ onDragEnd: handleDragEnd,
151
+ onDragStart: () => setIsDragging(true),
152
+ style: dragStyle
153
+ }, /*#__PURE__*/_react.default.createElement(_ui.Card, {
154
+ tone: "primary",
155
+ shadow: 3,
156
+ style: dotStyle
157
+ })));
158
+ }
159
+ //# sourceMappingURL=Spot.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/Spot.tsx"],"names":["dragStyle","width","height","position","dotStyle","borderRadius","pointerEvents","round","num","Math","Spot","spot","bounds","undefined","update","hotspotDescriptionPath","React","useState","x","y","setXY","rect","setRect","isDragging","setIsDragging","useEffect","clientRect","current","getBoundingClientRect","handleDragEnd","useCallback","event","srcElement","style","transform","split","map","v","match","pop","replace","currentX","currentY","Number","console","warn","newX","newY","safeX","max","min","safeY","_key","maxWidth"],"mappings":"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","sourcesContent":["import React from 'react'\nimport {Box, Card, Text, Tooltip} from '@sanity/ui'\nimport {motion} from 'framer-motion'\nimport get from 'lodash/get'\n\nconst dragStyle = {\n width: `1rem`,\n height: `1rem`,\n position: `absolute`,\n}\n\nconst dotStyle = {\n ...dragStyle,\n borderRadius: `50%`,\n // make sure pointer events only run on the parent\n pointerEvents: `none`,\n}\n\nconst round = (num) => Math.round(num * 100) / 100\n\nexport default function Spot({spot, bounds = undefined, update, hotspotDescriptionPath = ``}) {\n // x/y are stored as % but need to be displayed as px\n const [{x, y}, setXY] = React.useState({x: 0, y: 0})\n const [rect, setRect] = React.useState({width: 0, height: 0})\n const [isDragging, setIsDragging] = React.useState(false)\n\n React.useEffect(() => {\n const clientRect = bounds?.current?.getBoundingClientRect()\n\n if (clientRect) {\n // So convert % to px once we know the height/width of the image\n setXY({\n x: round(clientRect.width * (spot.x / 100)),\n y: round(clientRect.height * (spot.y / 100)),\n })\n\n setRect(clientRect)\n }\n }, [bounds, bounds.current])\n\n const handleDragEnd = React.useCallback(\n (event) => {\n setIsDragging(false)\n\n // I don't know why, but framer-motion doesn't give you the actual transform values\n // So we have to regex the `px` values off the inline styles\n const [currentX, currentY] = event.srcElement.style.transform.split(` `).map((v) => {\n return v\n ? v\n .match(/\\(([^)]+)\\)/)\n .pop()\n .replace(`px`, ``)\n : null\n })\n\n if (!Number(currentX) || !Number(currentY)) {\n return console.warn(`Missing or non-number X or Y`, {currentX, currentY}, event.srcElement)\n }\n\n // Which we need to convert back to `%` to patch the document\n const newX = round((currentX * 100) / rect.width)\n const newY = round((currentY * 100) / rect.height)\n\n // Don't go below 0 or above 100\n const safeX = Math.max(0, Math.min(100, newX))\n const safeY = Math.max(0, Math.min(100, newY))\n\n update(spot._key, safeX, safeY)\n },\n\n [spot]\n )\n\n if (!x || !y) {\n return null\n }\n\n return (\n <Tooltip\n key={spot._key}\n disabled={isDragging}\n boundaryElement={bounds.current}\n portal\n content={\n <Box padding={2} style={{maxWidth: 200, pointerEvents: `none`}}>\n <Text textOverflow=\"ellipsis\">{hotspotDescriptionPath ? get(spot, hotspotDescriptionPath) : `${spot.x}% x ${spot.y}%`}</Text>\n </Box>\n }\n >\n <motion.div\n drag\n dragConstraints={bounds}\n dragMomentum={false}\n initial={{x, y}}\n onDragEnd={handleDragEnd}\n onDragStart={() => setIsDragging(true)}\n style={dragStyle}\n >\n <Card tone=\"primary\" shadow={3} style={dotStyle} />\n </motion.div>\n </Tooltip>\n )\n}"],"file":"Spot.js"}
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.useUnsetInputComponent = useUnsetInputComponent;
7
+
8
+ var _react = _interopRequireDefault(require("react"));
9
+
10
+ function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
11
+
12
+ function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
13
+
14
+ function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
15
+
16
+ function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
17
+
18
+ function useUnsetInputComponent(type, component) {
19
+ return _react.default.useMemo(() => unsetInputComponent(type, component), [type, component]);
20
+ }
21
+
22
+ function unsetInputComponent(type, component) {
23
+ var t = _objectSpread(_objectSpread({}, type), {}, {
24
+ inputComponent: type.inputComponent === component ? undefined : type.inputComponent
25
+ });
26
+
27
+ var typeOfType = t.type ? unsetInputComponent(t.type, component) : undefined;
28
+ return _objectSpread(_objectSpread({}, t), {}, {
29
+ type: typeOfType
30
+ });
31
+ }
32
+ //# sourceMappingURL=useUnsetInputComponent.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/useUnsetInputComponent.ts"],"names":["useUnsetInputComponent","type","component","React","useMemo","unsetInputComponent","t","inputComponent","undefined","typeOfType"],"mappings":";;;;;;;AAAA;;;;;;;;;;AAEO,SAASA,sBAAT,CAAgCC,IAAhC,EAAsCC,SAAtC,EAAiD;AACpD,SAAOC,eAAMC,OAAN,CAAc,MAAMC,mBAAmB,CAACJ,IAAD,EAAOC,SAAP,CAAvC,EAA0D,CAACD,IAAD,EAAOC,SAAP,CAA1D,CAAP;AACD;;AAED,SAASG,mBAAT,CAA6BJ,IAA7B,EAAmCC,SAAnC,EAA8C;AAC5C,MAAMI,CAAC,mCACFL,IADE;AAELM,IAAAA,cAAc,EAAEN,IAAI,CAACM,cAAL,KAAwBL,SAAxB,GAAoCM,SAApC,GAAgDP,IAAI,CAACM;AAFhE,IAAP;;AAIA,MAAME,UAAU,GAAGH,CAAC,CAACL,IAAF,GAASI,mBAAmB,CAACC,CAAC,CAACL,IAAH,EAASC,SAAT,CAA5B,GAAkDM,SAArE;AACA,yCACKF,CADL;AAEEL,IAAAA,IAAI,EAAEQ;AAFR;AAID","sourcesContent":["import React from 'react'\n\nexport function useUnsetInputComponent(type, component) {\n return React.useMemo(() => unsetInputComponent(type, component), [type, component])\n }\n \n function unsetInputComponent(type, component) {\n const t = {\n ...type,\n inputComponent: type.inputComponent === component ? undefined : type.inputComponent,\n }\n const typeOfType = t.type ? unsetInputComponent(t.type, component) : undefined\n return {\n ...t,\n type: typeOfType,\n }\n }"],"file":"useUnsetInputComponent.js"}
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "sanity-plugin-hotspot-array",
3
+ "version": "0.0.1",
4
+ "description": "A configurable Custom Input for Arrays that will add and update items by clicking on an Image",
5
+ "main": "lib/HotspotArray.js",
6
+ "scripts": {
7
+ "lint": "eslint .",
8
+ "build": "sanipack build",
9
+ "watch": "sanipack build --watch",
10
+ "prepublishOnly": "sanipack build && sanipack verify"
11
+ },
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+ssh://git@github.com/SimeonGriggs/sanity-plugin-hotspot-array.git"
15
+ },
16
+ "keywords": [
17
+ "sanity",
18
+ "sanity-plugin"
19
+ ],
20
+ "author": "Simeon Griggs <simeon@sanity.io>",
21
+ "license": "MIT",
22
+ "dependencies": {
23
+ "@sanity/asset-utils": "^1.2.3",
24
+ "@sanity/base": "^2.23.2",
25
+ "@sanity/form-builder": "^2.23.2",
26
+ "@sanity/ui": "^0.36.17",
27
+ "framer-motion": "^5.5.5",
28
+ "prop-types": "^15.7.2"
29
+ },
30
+ "devDependencies": {
31
+ "eslint": "^7.32.0",
32
+ "eslint-config-prettier": "^8.3.0",
33
+ "eslint-config-sanity": "^5.1.0",
34
+ "eslint-plugin-react": "^7.27.1",
35
+ "prettier": "^2.5.1",
36
+ "sanipack": "^2.1.0"
37
+ },
38
+ "peerDependencies": {
39
+ "react": "^17.0.0",
40
+ "@sanity/util": "2.23.2",
41
+ "@sanity/image-url": "1.0.1",
42
+ "lodash": "4.17.21"
43
+ },
44
+ "bugs": {
45
+ "url": "https://github.com/SimeonGriggs/sanity-plugin-hotspot-array/issues"
46
+ },
47
+ "homepage": "https://github.com/SimeonGriggs/sanity-plugin-hotspot-array#readme",
48
+ "prettier": {
49
+ "semi": false,
50
+ "printWidth": 100,
51
+ "bracketSpacing": false,
52
+ "singleQuote": true
53
+ },
54
+ "eslintConfig": {
55
+ "parser": "sanipack/babel/eslint-parser",
56
+ "extends": [
57
+ "sanity",
58
+ "sanity/react",
59
+ "prettier"
60
+ ],
61
+ "ignorePatterns": [
62
+ "lib/**/"
63
+ ]
64
+ }
65
+ }
package/sanity.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "paths": {
3
+ "source": "./src",
4
+ "compiled": "./lib"
5
+ },
6
+ "parts": []
7
+ }
@@ -0,0 +1,10 @@
1
+ import React from 'react'
2
+ import {Card, Text} from '@sanity/ui'
3
+
4
+ export default function Feedback({children}) {
5
+ return (
6
+ <Card padding={4} radius={2} shadow={1} tone="caution">
7
+ <Text>{children}</Text>
8
+ </Card>
9
+ )
10
+ }
@@ -0,0 +1,136 @@
1
+ /* eslint-disable react/display-name */
2
+
3
+ // @ts-ignore
4
+ import sanityClient from 'part:@sanity/base/client'
5
+ // @ts-ignore
6
+ import {withDocument} from 'part:@sanity/form-builder'
7
+
8
+ import React from 'react'
9
+ import {Card, Flex, Stack, Text} from '@sanity/ui'
10
+ import {FormBuilderInput} from '@sanity/form-builder/lib/FormBuilderInput'
11
+ import {randomKey} from '@sanity/util/content'
12
+ import {PatchEvent, setIfMissing, set, insert} from '@sanity/form-builder/PatchEvent'
13
+ import imageUrlBuilder from '@sanity/image-url'
14
+ import {getImageDimensions} from '@sanity/asset-utils'
15
+ import get from 'lodash/get'
16
+
17
+ import Spot from './Spot'
18
+ import { useUnsetInputComponent } from './useUnsetInputComponent'
19
+ import Feedback from './Feedback'
20
+
21
+ const builder = imageUrlBuilder(sanityClient)
22
+ const urlFor = (source) => builder.image(source)
23
+ const imageStyle = {width: `100%`, height: `auto`}
24
+
25
+
26
+ const HotspotArray = React.forwardRef((props, ref) => {
27
+ const {type, value, onChange, document: sanityDocument} = props
28
+
29
+ // Attempt prevention of infinite loop in <FormBuilderInput />
30
+ // Re-renders can still occur if this Component is used again in a nested field
31
+ const typeWithoutInputComponent = useUnsetInputComponent(type, type?.inputComponent)
32
+
33
+ // Finding the image from the document,
34
+ // using the path from the hotspot's `options` field
35
+ const displayImage = React.useMemo(() => {
36
+ const hotspotImage = get(sanityDocument, type?.options?.hotspotImagePath)
37
+
38
+ if (hotspotImage?.asset?._ref) {
39
+ const {aspectRatio} = getImageDimensions(hotspotImage.asset._ref)
40
+ const width = 600
41
+ const height = Math.round(width / aspectRatio)
42
+ const url = urlFor(hotspotImage).width(width).url()
43
+
44
+ return {width, height, url}
45
+ }
46
+
47
+ return null
48
+ }, [sanityDocument, type])
49
+
50
+ const handleHotspotImageClick = React.useCallback((event) => {
51
+ const {nativeEvent} = event
52
+
53
+ // Calculate the x/y percentage of the click position
54
+ const x = Number(((nativeEvent.offsetX * 100) / nativeEvent.srcElement.width).toFixed(2))
55
+ const y = Number(((nativeEvent.offsetY * 100) / nativeEvent.srcElement.height).toFixed(2))
56
+ const description = `New Hotspot at ${x}% x ${y}%`
57
+
58
+ const newRow = {
59
+ _key: randomKey(12),
60
+ _type: `spot`,
61
+ x,
62
+ y,
63
+ }
64
+
65
+ if (type?.options?.hotspotDescriptionPath) {
66
+ console.log(type.options.hotspotDescriptionPath);
67
+ newRow[type.options.hotspotDescriptionPath] = description
68
+ }
69
+
70
+ onChange(PatchEvent.from(setIfMissing([]), insert([newRow], 'after', [-1])))
71
+ }, [])
72
+
73
+ const handleHotspotMove = React.useCallback(
74
+ (key, x, y) => {
75
+ if (!Number(x) || !Number(y)) {
76
+ console.warn(`Missing or non-number X or Y`, {x, y})
77
+ return
78
+ }
79
+
80
+ onChange(
81
+ PatchEvent.from(
82
+ // Set the `x` value of this array key item
83
+ set(x, [{_key: key}, 'x']),
84
+ // Set the `y` value of this array key item
85
+ set(y, [{_key: key}, 'y'])
86
+ )
87
+ )
88
+ },
89
+ [value]
90
+ )
91
+
92
+ const hotspotImageRef = React.useRef(null)
93
+
94
+ return (
95
+ <Stack space={[2,2,3]}>
96
+ {displayImage?.url ? (
97
+ <div style={{position: `relative`}}>
98
+ {value?.length > 0 &&
99
+ value.map((spot) => (
100
+ <Spot
101
+ key={spot._key}
102
+ spot={spot}
103
+ bounds={hotspotImageRef}
104
+ update={handleHotspotMove}
105
+ hotspotDescriptionPath={type?.options?.hotspotDescriptionPath}
106
+ />
107
+ ))}
108
+
109
+ <Card __unstable_checkered shadow={1}>
110
+ <Flex align="center" justify="center">
111
+ <img
112
+ ref={hotspotImageRef}
113
+ src={displayImage.url}
114
+ width={displayImage.width}
115
+ height={displayImage.height}
116
+ alt=""
117
+ style={imageStyle}
118
+ onClick={handleHotspotImageClick}
119
+ />
120
+ </Flex>
121
+ </Card>
122
+ </div>
123
+ ) : (
124
+ <Feedback>
125
+ {type?.options?.hotspotImagePath
126
+ ? <>No Hotspot image found at path <code>{type?.options?.hotspotImagePath}</code></>
127
+ : <>Define a path in this field using to the image field in this document at <code>options.hotspotImagePath</code></>
128
+ }
129
+ </Feedback>
130
+ )}
131
+ <FormBuilderInput {...props} type={typeWithoutInputComponent} ref={ref} />
132
+ </Stack>
133
+ )
134
+ })
135
+
136
+ export default withDocument(HotspotArray)
package/src/Spot.tsx ADDED
@@ -0,0 +1,103 @@
1
+ import React from 'react'
2
+ import {Box, Card, Text, Tooltip} from '@sanity/ui'
3
+ import {motion} from 'framer-motion'
4
+ import get from 'lodash/get'
5
+
6
+ const dragStyle = {
7
+ width: `1rem`,
8
+ height: `1rem`,
9
+ position: `absolute`,
10
+ }
11
+
12
+ const dotStyle = {
13
+ ...dragStyle,
14
+ borderRadius: `50%`,
15
+ // make sure pointer events only run on the parent
16
+ pointerEvents: `none`,
17
+ }
18
+
19
+ const round = (num) => Math.round(num * 100) / 100
20
+
21
+ export default function Spot({spot, bounds = undefined, update, hotspotDescriptionPath = ``}) {
22
+ // x/y are stored as % but need to be displayed as px
23
+ const [{x, y}, setXY] = React.useState({x: 0, y: 0})
24
+ const [rect, setRect] = React.useState({width: 0, height: 0})
25
+ const [isDragging, setIsDragging] = React.useState(false)
26
+
27
+ React.useEffect(() => {
28
+ const clientRect = bounds?.current?.getBoundingClientRect()
29
+
30
+ if (clientRect) {
31
+ // So convert % to px once we know the height/width of the image
32
+ setXY({
33
+ x: round(clientRect.width * (spot.x / 100)),
34
+ y: round(clientRect.height * (spot.y / 100)),
35
+ })
36
+
37
+ setRect(clientRect)
38
+ }
39
+ }, [bounds, bounds.current])
40
+
41
+ const handleDragEnd = React.useCallback(
42
+ (event) => {
43
+ setIsDragging(false)
44
+
45
+ // I don't know why, but framer-motion doesn't give you the actual transform values
46
+ // So we have to regex the `px` values off the inline styles
47
+ const [currentX, currentY] = event.srcElement.style.transform.split(` `).map((v) => {
48
+ return v
49
+ ? v
50
+ .match(/\(([^)]+)\)/)
51
+ .pop()
52
+ .replace(`px`, ``)
53
+ : null
54
+ })
55
+
56
+ if (!Number(currentX) || !Number(currentY)) {
57
+ return console.warn(`Missing or non-number X or Y`, {currentX, currentY}, event.srcElement)
58
+ }
59
+
60
+ // Which we need to convert back to `%` to patch the document
61
+ const newX = round((currentX * 100) / rect.width)
62
+ const newY = round((currentY * 100) / rect.height)
63
+
64
+ // Don't go below 0 or above 100
65
+ const safeX = Math.max(0, Math.min(100, newX))
66
+ const safeY = Math.max(0, Math.min(100, newY))
67
+
68
+ update(spot._key, safeX, safeY)
69
+ },
70
+
71
+ [spot]
72
+ )
73
+
74
+ if (!x || !y) {
75
+ return null
76
+ }
77
+
78
+ return (
79
+ <Tooltip
80
+ key={spot._key}
81
+ disabled={isDragging}
82
+ boundaryElement={bounds.current}
83
+ portal
84
+ content={
85
+ <Box padding={2} style={{maxWidth: 200, pointerEvents: `none`}}>
86
+ <Text textOverflow="ellipsis">{hotspotDescriptionPath ? get(spot, hotspotDescriptionPath) : `${spot.x}% x ${spot.y}%`}</Text>
87
+ </Box>
88
+ }
89
+ >
90
+ <motion.div
91
+ drag
92
+ dragConstraints={bounds}
93
+ dragMomentum={false}
94
+ initial={{x, y}}
95
+ onDragEnd={handleDragEnd}
96
+ onDragStart={() => setIsDragging(true)}
97
+ style={dragStyle}
98
+ >
99
+ <Card tone="primary" shadow={3} style={dotStyle} />
100
+ </motion.div>
101
+ </Tooltip>
102
+ )
103
+ }
@@ -0,0 +1,17 @@
1
+ import React from 'react'
2
+
3
+ export function useUnsetInputComponent(type, component) {
4
+ return React.useMemo(() => unsetInputComponent(type, component), [type, component])
5
+ }
6
+
7
+ function unsetInputComponent(type, component) {
8
+ const t = {
9
+ ...type,
10
+ inputComponent: type.inputComponent === component ? undefined : type.inputComponent,
11
+ }
12
+ const typeOfType = t.type ? unsetInputComponent(t.type, component) : undefined
13
+ return {
14
+ ...t,
15
+ type: typeOfType,
16
+ }
17
+ }