mirror of
https://github.com/storybookjs/storybook.git
synced 2025-04-05 16:11:33 +08:00
336 lines
8.8 KiB
TypeScript
336 lines
8.8 KiB
TypeScript
import global from 'global';
|
|
import cloneDeep from 'lodash/cloneDeep';
|
|
import React, {
|
|
ComponentProps,
|
|
SyntheticEvent,
|
|
useCallback,
|
|
useMemo,
|
|
useState,
|
|
useEffect,
|
|
useRef,
|
|
FC,
|
|
FocusEvent,
|
|
} from 'react';
|
|
import { styled, useTheme, Theme } from '@storybook/theming';
|
|
import { Form, Icons, IconsProps, IconButton } from '@storybook/components';
|
|
import { JsonTree, getObjectType } from './react-editable-json-tree';
|
|
import { getControlId, getControlSetterButtonId } from './helpers';
|
|
import type { ControlProps, ObjectValue, ObjectConfig } from './types';
|
|
|
|
const { window: globalWindow } = global;
|
|
|
|
type JsonTreeProps = ComponentProps<typeof JsonTree>;
|
|
|
|
const Wrapper = styled.div(({ theme }) => ({
|
|
position: 'relative',
|
|
display: 'flex',
|
|
|
|
'.rejt-tree': {
|
|
marginLeft: '1rem',
|
|
fontSize: '13px',
|
|
},
|
|
'.rejt-value-node, .rejt-object-node > .rejt-collapsed, .rejt-array-node > .rejt-collapsed, .rejt-object-node > .rejt-not-collapsed, .rejt-array-node > .rejt-not-collapsed':
|
|
{
|
|
'& > svg': {
|
|
opacity: 0,
|
|
transition: 'opacity 0.2s',
|
|
},
|
|
},
|
|
'.rejt-value-node:hover, .rejt-object-node:hover > .rejt-collapsed, .rejt-array-node:hover > .rejt-collapsed, .rejt-object-node:hover > .rejt-not-collapsed, .rejt-array-node:hover > .rejt-not-collapsed':
|
|
{
|
|
'& > svg': {
|
|
opacity: 1,
|
|
},
|
|
},
|
|
'.rejt-edit-form button': {
|
|
display: 'none',
|
|
},
|
|
'.rejt-add-form': {
|
|
marginLeft: 10,
|
|
},
|
|
'.rejt-add-value-node': {
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
},
|
|
'.rejt-name': {
|
|
lineHeight: '22px',
|
|
},
|
|
'.rejt-not-collapsed-delimiter': {
|
|
lineHeight: '22px',
|
|
},
|
|
'.rejt-plus-menu': {
|
|
marginLeft: 5,
|
|
},
|
|
'.rejt-object-node > span > *': {
|
|
position: 'relative',
|
|
zIndex: 2,
|
|
},
|
|
'.rejt-object-node, .rejt-array-node': {
|
|
position: 'relative',
|
|
},
|
|
'.rejt-object-node > span:first-of-type::after, .rejt-array-node > span:first-of-type::after, .rejt-collapsed::before, .rejt-not-collapsed::before':
|
|
{
|
|
content: '""',
|
|
position: 'absolute',
|
|
top: 0,
|
|
display: 'block',
|
|
width: '100%',
|
|
marginLeft: '-1rem',
|
|
padding: '0 4px 0 1rem',
|
|
height: 22,
|
|
},
|
|
'.rejt-collapsed::before, .rejt-not-collapsed::before': {
|
|
zIndex: 1,
|
|
background: 'transparent',
|
|
borderRadius: 4,
|
|
transition: 'background 0.2s',
|
|
pointerEvents: 'none',
|
|
opacity: 0.1,
|
|
},
|
|
'.rejt-object-node:hover, .rejt-array-node:hover': {
|
|
'& > .rejt-collapsed::before, & > .rejt-not-collapsed::before': {
|
|
background: theme.color.secondary,
|
|
},
|
|
},
|
|
'.rejt-collapsed::after, .rejt-not-collapsed::after': {
|
|
content: '""',
|
|
position: 'absolute',
|
|
display: 'inline-block',
|
|
pointerEvents: 'none',
|
|
width: 0,
|
|
height: 0,
|
|
},
|
|
'.rejt-collapsed::after': {
|
|
left: -8,
|
|
top: 8,
|
|
borderTop: '3px solid transparent',
|
|
borderBottom: '3px solid transparent',
|
|
borderLeft: '3px solid rgba(153,153,153,0.6)',
|
|
},
|
|
'.rejt-not-collapsed::after': {
|
|
left: -10,
|
|
top: 10,
|
|
borderTop: '3px solid rgba(153,153,153,0.6)',
|
|
borderLeft: '3px solid transparent',
|
|
borderRight: '3px solid transparent',
|
|
},
|
|
'.rejt-value': {
|
|
display: 'inline-block',
|
|
border: '1px solid transparent',
|
|
borderRadius: 4,
|
|
margin: '1px 0',
|
|
padding: '0 4px',
|
|
cursor: 'text',
|
|
color: theme.color.defaultText,
|
|
},
|
|
'.rejt-value-node:hover > .rejt-value': {
|
|
background: theme.color.lighter,
|
|
borderColor: theme.appBorderColor,
|
|
},
|
|
}));
|
|
|
|
const Button = styled.button<{ primary?: boolean }>(({ theme, primary }) => ({
|
|
border: 0,
|
|
height: 20,
|
|
margin: 1,
|
|
borderRadius: 4,
|
|
background: primary ? theme.color.secondary : 'transparent',
|
|
color: primary ? theme.color.lightest : theme.color.dark,
|
|
fontWeight: primary ? 'bold' : 'normal',
|
|
cursor: 'pointer',
|
|
order: primary ? 'initial' : 9,
|
|
}));
|
|
|
|
type ActionIconProps = IconsProps & { disabled?: boolean };
|
|
|
|
const ActionIcon = styled(Icons)(({ theme, icon, disabled }: ActionIconProps) => ({
|
|
display: 'inline-block',
|
|
verticalAlign: 'middle',
|
|
width: 15,
|
|
height: 15,
|
|
padding: 3,
|
|
marginLeft: 5,
|
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
|
color: theme.textMutedColor,
|
|
'&:hover': disabled
|
|
? {}
|
|
: {
|
|
color: icon === 'subtract' ? theme.color.negative : theme.color.ancillary,
|
|
},
|
|
'svg + &': {
|
|
marginLeft: 0,
|
|
},
|
|
}));
|
|
|
|
const Input = styled.input(({ theme, placeholder }) => ({
|
|
outline: 0,
|
|
margin: placeholder ? 1 : '1px 0',
|
|
padding: '3px 4px',
|
|
color: theme.color.defaultText,
|
|
background: theme.background.app,
|
|
border: `1px solid ${theme.appBorderColor}`,
|
|
borderRadius: 4,
|
|
lineHeight: '14px',
|
|
width: placeholder === 'Key' ? 80 : 120,
|
|
'&:focus': {
|
|
border: `1px solid ${theme.color.secondary}`,
|
|
},
|
|
}));
|
|
|
|
const RawButton = styled(IconButton)(({ theme }) => ({
|
|
position: 'absolute',
|
|
zIndex: 2,
|
|
top: 2,
|
|
right: 2,
|
|
height: 21,
|
|
padding: '0 3px',
|
|
background: theme.background.bar,
|
|
border: `1px solid ${theme.appBorderColor}`,
|
|
borderRadius: 3,
|
|
color: theme.textMutedColor,
|
|
fontSize: '9px',
|
|
fontWeight: 'bold',
|
|
textDecoration: 'none',
|
|
span: {
|
|
marginLeft: 3,
|
|
marginTop: 1,
|
|
},
|
|
}));
|
|
|
|
const RawInput = styled(Form.Textarea)(({ theme }) => ({
|
|
flex: 1,
|
|
padding: '7px 6px',
|
|
fontFamily: theme.typography.fonts.mono,
|
|
fontSize: '12px',
|
|
lineHeight: '18px',
|
|
'&::placeholder': {
|
|
fontFamily: theme.typography.fonts.base,
|
|
fontSize: '13px',
|
|
},
|
|
'&:placeholder-shown': {
|
|
padding: '7px 10px',
|
|
},
|
|
}));
|
|
|
|
const ENTER_EVENT = { bubbles: true, cancelable: true, key: 'Enter', code: 'Enter', keyCode: 13 };
|
|
const dispatchEnterKey = (event: SyntheticEvent<HTMLInputElement>) => {
|
|
event.currentTarget.dispatchEvent(new globalWindow.KeyboardEvent('keydown', ENTER_EVENT));
|
|
};
|
|
const selectValue = (event: SyntheticEvent<HTMLInputElement>) => {
|
|
event.currentTarget.select();
|
|
};
|
|
|
|
export type ObjectProps = ControlProps<ObjectValue> &
|
|
ObjectConfig & {
|
|
theme: any; // TODO: is there a type for this?
|
|
};
|
|
|
|
const getCustomStyleFunction: (theme: Theme) => JsonTreeProps['getStyle'] = (theme) => () => ({
|
|
name: {
|
|
color: theme.color.secondary,
|
|
},
|
|
collapsed: {
|
|
color: theme.color.dark,
|
|
},
|
|
ul: {
|
|
listStyle: 'none',
|
|
margin: '0 0 0 1rem',
|
|
padding: 0,
|
|
},
|
|
li: {
|
|
outline: 0,
|
|
},
|
|
});
|
|
|
|
export const ObjectControl: FC<ObjectProps> = ({ name, value, onChange }) => {
|
|
const theme = useTheme();
|
|
const data = useMemo(() => value && cloneDeep(value), [value]);
|
|
const hasData = data !== null && data !== undefined;
|
|
|
|
const [showRaw, setShowRaw] = useState(!hasData);
|
|
const [parseError, setParseError] = useState<Error>(null);
|
|
const updateRaw: (raw: string) => void = useCallback(
|
|
(raw) => {
|
|
try {
|
|
if (raw) onChange(JSON.parse(raw));
|
|
setParseError(undefined);
|
|
} catch (e) {
|
|
setParseError(e);
|
|
}
|
|
},
|
|
[onChange]
|
|
);
|
|
|
|
const [forceVisible, setForceVisible] = useState(false);
|
|
const onForceVisible = useCallback(() => {
|
|
onChange({});
|
|
setForceVisible(true);
|
|
}, [setForceVisible]);
|
|
|
|
const htmlElRef = useRef(null);
|
|
useEffect(() => {
|
|
if (forceVisible && htmlElRef.current) htmlElRef.current.select();
|
|
}, [forceVisible]);
|
|
|
|
if (!hasData) {
|
|
return (
|
|
<Form.Button id={getControlSetterButtonId(name)} onClick={onForceVisible}>
|
|
Set object
|
|
</Form.Button>
|
|
);
|
|
}
|
|
|
|
const rawJSONForm = (
|
|
<RawInput
|
|
ref={htmlElRef}
|
|
id={getControlId(name)}
|
|
name={name}
|
|
defaultValue={value === null ? '' : JSON.stringify(value, null, 2)}
|
|
onBlur={(event: FocusEvent<HTMLTextAreaElement>) => updateRaw(event.target.value)}
|
|
placeholder="Edit JSON string..."
|
|
autoFocus={forceVisible}
|
|
valid={parseError ? 'error' : null}
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<Wrapper>
|
|
{['Object', 'Array'].includes(getObjectType(data)) && (
|
|
<RawButton
|
|
href="#"
|
|
onClick={(e: SyntheticEvent) => {
|
|
e.preventDefault();
|
|
setShowRaw((v) => !v);
|
|
}}
|
|
>
|
|
<Icons icon={showRaw ? 'eyeclose' : 'eye'} />
|
|
<span>RAW</span>
|
|
</RawButton>
|
|
)}
|
|
{!showRaw ? (
|
|
<JsonTree
|
|
data={data}
|
|
rootName={name}
|
|
onFullyUpdate={onChange}
|
|
getStyle={getCustomStyleFunction(theme)}
|
|
cancelButtonElement={<Button type="button">Cancel</Button>}
|
|
editButtonElement={<Button type="submit">Save</Button>}
|
|
addButtonElement={
|
|
<Button type="submit" primary>
|
|
Save
|
|
</Button>
|
|
}
|
|
plusMenuElement={<ActionIcon icon="add" />}
|
|
minusMenuElement={<ActionIcon icon="subtract" />}
|
|
inputElement={(_: any, __: any, ___: any, key: string) =>
|
|
key ? <Input onFocus={selectValue} onBlur={dispatchEnterKey} /> : <Input />
|
|
}
|
|
fallback={rawJSONForm}
|
|
/>
|
|
) : (
|
|
rawJSONForm
|
|
)}
|
|
</Wrapper>
|
|
);
|
|
};
|