main
js 296 lines 7.71 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 *
7 * @flow
8 */
9
10 import * as React from 'react';
11 import {useContext, useMemo, useRef, useState} from 'react';
12 import {copy} from 'clipboard-js';
13 import {
14 BridgeContext,
15 StoreContext,
16 } from 'react-devtools-shared/src/devtools/views/context';
17 import Button from '../../Button';
18 import ButtonIcon from '../../ButtonIcon';
19 import {serializeDataForCopy} from '../../utils';
20 import AutoSizeInput from './AutoSizeInput';
21 import styles from './StyleEditor.css';
22 import {sanitizeForParse} from '../../../utils';
23 import {withPermissionsCheck} from 'react-devtools-shared/src/frontend/utils/withPermissionsCheck';
24
25 import type {Style} from './types';
26
27 type Props = {
28 id: number,
29 style: Style,
30 };
31
32 type ChangeAttributeFn = (oldName: string, newName: string, value: any) => void;
33 type ChangeValueFn = (name: string, value: any) => void;
34
35 export default function StyleEditor({id, style}: Props): React.Node {
36 const bridge = useContext(BridgeContext);
37 const store = useContext(StoreContext);
38
39 const changeAttribute = (oldName: string, newName: string, value: any) => {
40 const rendererID = store.getRendererIDForElement(id);
41 if (rendererID !== null) {
42 bridge.send('NativeStyleEditor_renameAttribute', {
43 id,
44 rendererID,
45 oldName,
46 newName,
47 value,
48 });
49 }
50 };
51
52 const changeValue = (name: string, value: any) => {
53 const rendererID = store.getRendererIDForElement(id);
54 if (rendererID !== null) {
55 bridge.send('NativeStyleEditor_setValue', {
56 id,
57 rendererID,
58 name,
59 value,
60 });
61 }
62 };
63
64 const keys = useMemo(() => Array.from(Object.keys(style)), [style]);
65
66 const handleCopy = withPermissionsCheck(
67 {permissions: ['clipboardWrite']},
68 () => copy(serializeDataForCopy(style)),
69 );
70
71 return (
72 <div className={styles.StyleEditor}>
73 <div className={styles.HeaderRow}>
74 <div className={styles.Header}>
75 <div className={styles.Brackets}>{'style {'}</div>
76 </div>
77 <Button onClick={handleCopy} title="Copy to clipboard">
78 <ButtonIcon type="copy" />
79 </Button>
80 </div>
81 {keys.length > 0 &&
82 keys.map(attribute => (
83 <Row
84 key={`${attribute}/${style[attribute]}`}
85 attribute={attribute}
86 changeAttribute={changeAttribute}
87 changeValue={changeValue}
88 validAttributes={store.nativeStyleEditorValidAttributes}
89 value={style[attribute]}
90 />
91 ))}
92 <NewRow
93 changeAttribute={changeAttribute}
94 changeValue={changeValue}
95 validAttributes={store.nativeStyleEditorValidAttributes}
96 />
97 <div className={styles.Brackets}>{'}'}</div>
98 </div>
99 );
100 }
101
102 type NewRowProps = {
103 changeAttribute: ChangeAttributeFn,
104 changeValue: ChangeValueFn,
105 validAttributes: $ReadOnlyArray<string> | null,
106 };
107
108 function NewRow({changeAttribute, changeValue, validAttributes}: NewRowProps) {
109 const [key, setKey] = useState<number>(0);
110 const reset = () => setKey(key + 1);
111
112 const newAttributeRef = useRef<string>('');
113
114 const changeAttributeWrapper = (
115 oldAttribute: string,
116 newAttribute: string,
117 value: any,
118 ) => {
119 // Ignore attribute changes until a value has been specified
120 newAttributeRef.current = newAttribute;
121 };
122
123 const changeValueWrapper = (attribute: string, value: any) => {
124 // Blur events should reset/cancel if there's no value or no attribute
125 if (newAttributeRef.current !== '') {
126 if (value !== '') {
127 changeValue(newAttributeRef.current, value);
128 }
129 reset();
130 }
131 };
132
133 return (
134 <Row
135 key={key}
136 attribute={''}
137 attributePlaceholder="attribute"
138 changeAttribute={changeAttributeWrapper}
139 changeValue={changeValueWrapper}
140 validAttributes={validAttributes}
141 value={''}
142 valuePlaceholder="value"
143 />
144 );
145 }
146
147 type RowProps = {
148 attribute: string,
149 attributePlaceholder?: string,
150 changeAttribute: ChangeAttributeFn,
151 changeValue: ChangeValueFn,
152 validAttributes: $ReadOnlyArray<string> | null,
153 value: any,
154 valuePlaceholder?: string,
155 };
156
157 function Row({
158 attribute,
159 attributePlaceholder,
160 changeAttribute,
161 changeValue,
162 validAttributes,
163 value,
164 valuePlaceholder,
165 }: RowProps) {
166 // TODO (RN style editor) Use @reach/combobox to auto-complete attributes.
167 // The list of valid attributes would need to be injected by RN backend,
168 // which would need to require them from ReactNativeViewViewConfig "validAttributes.style" keys.
169 // This would need to degrade gracefully for react-native-web,
170 // although we could let it also inject a custom set of allowed attributes.
171
172 const [localAttribute, setLocalAttribute] = useState(attribute);
173 const [localValue, setLocalValue] = useState(JSON.stringify(value));
174 const [isAttributeValid, setIsAttributeValid] = useState(true);
175 const [isValueValid, setIsValueValid] = useState(true);
176
177 // $FlowFixMe[missing-local-annot]
178 const validateAndSetLocalAttribute = newAttribute => {
179 const isValid =
180 newAttribute === '' ||
181 validAttributes === null ||
182 validAttributes.indexOf(newAttribute) >= 0;
183
184 setLocalAttribute(newAttribute);
185 setIsAttributeValid(isValid);
186 };
187
188 // $FlowFixMe[missing-local-annot]
189 const validateAndSetLocalValue = newValue => {
190 let isValid = false;
191 try {
192 JSON.parse(sanitizeForParse(newValue));
193 isValid = true;
194 } catch (error) {}
195
196 setLocalValue(newValue);
197 setIsValueValid(isValid);
198 };
199
200 const resetAttribute = () => {
201 setLocalAttribute(attribute);
202 };
203
204 const resetValue = () => {
205 setLocalValue(value);
206 };
207
208 const submitValueChange = () => {
209 if (isAttributeValid && isValueValid) {
210 const parsedLocalValue = JSON.parse(sanitizeForParse(localValue));
211 if (value !== parsedLocalValue) {
212 changeValue(attribute, parsedLocalValue);
213 }
214 }
215 };
216
217 const submitAttributeChange = () => {
218 if (isAttributeValid && isValueValid) {
219 if (attribute !== localAttribute) {
220 changeAttribute(attribute, localAttribute, value);
221 }
222 }
223 };
224
225 return (
226 <div className={styles.Row}>
227 <Field
228 className={isAttributeValid ? styles.Attribute : styles.Invalid}
229 onChange={validateAndSetLocalAttribute}
230 onReset={resetAttribute}
231 onSubmit={submitAttributeChange}
232 placeholder={attributePlaceholder}
233 value={localAttribute}
234 />
235 :&nbsp;
236 <Field
237 className={isValueValid ? styles.Value : styles.Invalid}
238 onChange={validateAndSetLocalValue}
239 onReset={resetValue}
240 onSubmit={submitValueChange}
241 placeholder={valuePlaceholder}
242 value={localValue}
243 />
244 ;
245 </div>
246 );
247 }
248
249 type FieldProps = {
250 className: string,
251 onChange: (value: any) => void,
252 onReset: () => void,
253 onSubmit: () => void,
254 placeholder?: string,
255 value: any,
256 };
257
258 function Field({
259 className,
260 onChange,
261 onReset,
262 onSubmit,
263 placeholder,
264 value,
265 }: FieldProps) {
266 // $FlowFixMe[missing-local-annot]
267 const onKeyDown = event => {
268 switch (event.key) {
269 case 'Enter':
270 onSubmit();
271 break;
272 case 'Escape':
273 onReset();
274 break;
275 case 'ArrowDown':
276 case 'ArrowLeft':
277 case 'ArrowRight':
278 case 'ArrowUp':
279 event.stopPropagation();
280 break;
281 default:
282 break;
283 }
284 };
285
286 return (
287 <AutoSizeInput
288 className={`${className} ${styles.Input}`}
289 onBlur={onSubmit}
290 onChange={(event: $FlowFixMe) => onChange(event.target.value)}
291 onKeyDown={onKeyDown}
292 placeholder={placeholder}
293 value={value}
294 />
295 );
296 }