Use rich text editor in translations
This commit is contained in:
parent
55d9a1c986
commit
02e93b06d6
9 changed files with 240 additions and 176 deletions
|
@ -1,136 +1,23 @@
|
|||
import EditorJS, { OutputData } from "@editorjs/editorjs";
|
||||
import Header from "@editorjs/header";
|
||||
import List from "@editorjs/list";
|
||||
import Quote from "@editorjs/quote";
|
||||
import FormControl from "@material-ui/core/FormControl";
|
||||
import FormHelperText from "@material-ui/core/FormHelperText";
|
||||
import InputLabel from "@material-ui/core/InputLabel";
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
import { fade } from "@material-ui/core/styles/colorManipulator";
|
||||
import strikethroughIcon from "@saleor/icons/StrikethroughIcon";
|
||||
import classNames from "classnames";
|
||||
import createGenericInlineTool from "editorjs-inline-tool";
|
||||
import React from "react";
|
||||
|
||||
import { RichTextEditorContentProps, tools } from "./RichTextEditorContent";
|
||||
import useStyles from "./styles";
|
||||
|
||||
export type RichTextEditorChange = (data: OutputData) => void;
|
||||
export interface RichTextEditorProps {
|
||||
data: OutputData;
|
||||
export interface RichTextEditorProps extends RichTextEditorContentProps {
|
||||
disabled: boolean;
|
||||
error: boolean;
|
||||
helperText: string;
|
||||
label: string;
|
||||
name: string;
|
||||
onChange: RichTextEditorChange;
|
||||
onReady?: () => void;
|
||||
}
|
||||
|
||||
// TODO: improve dark mode
|
||||
const useStyles = makeStyles(
|
||||
theme => {
|
||||
const hover = {
|
||||
"&:hover": {
|
||||
background: fade(theme.palette.primary.main, 0.1)
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
root: {
|
||||
"& .cdx-quote__text": {
|
||||
minHeight: 24
|
||||
},
|
||||
"& .ce-block--selected .ce-block__content": {
|
||||
background: `${fade(theme.palette.primary.main, 0.2)} !important`
|
||||
},
|
||||
"& .ce-block__content": {
|
||||
margin: 0,
|
||||
maxWidth: "unset"
|
||||
},
|
||||
"& .ce-conversion-tool": {
|
||||
...hover
|
||||
},
|
||||
"& .ce-conversion-tool--focused": {
|
||||
background: `${fade(theme.palette.primary.main, 0.1)} !important`
|
||||
},
|
||||
"& .ce-conversion-tool__icon": {
|
||||
background: "none"
|
||||
},
|
||||
"& .ce-conversion-toolbar": {
|
||||
background: theme.palette.background.paper
|
||||
},
|
||||
"& .ce-header": {
|
||||
marginBottom: 0,
|
||||
paddingBottom: theme.spacing(1)
|
||||
},
|
||||
"& .ce-inline-tool": {
|
||||
...hover,
|
||||
color: theme.palette.text.primary,
|
||||
height: 32,
|
||||
transition: theme.transitions.duration.short + "ms",
|
||||
width: 32
|
||||
},
|
||||
"& .ce-inline-toolbar": {
|
||||
"& input": {
|
||||
background: "none"
|
||||
},
|
||||
background: theme.palette.background.paper,
|
||||
color: theme.palette.text.primary
|
||||
},
|
||||
"& .ce-inline-toolbar__dropdown": {
|
||||
...hover,
|
||||
height: 32,
|
||||
marginRight: 0
|
||||
},
|
||||
"& .ce-inline-toolbar__toggler-and-button-wrapper": {
|
||||
paddingRight: 0
|
||||
},
|
||||
"& .ce-toolbar__actions": {
|
||||
right: 0,
|
||||
top: 0
|
||||
},
|
||||
"& .ce-toolbar__content": {
|
||||
maxWidth: "unset"
|
||||
},
|
||||
"& .ce-toolbar__plus": {
|
||||
left: -9
|
||||
},
|
||||
"& .ce-toolbox.ce-toolbox--opened": {
|
||||
left: 16
|
||||
},
|
||||
"& .codex-editor__redactor": {
|
||||
marginRight: `${theme.spacing(4)}px !important`,
|
||||
paddingBottom: "0 !important"
|
||||
},
|
||||
"& a": {
|
||||
color: theme.palette.primary.light
|
||||
},
|
||||
"&:not($rootDisabled):hover": {
|
||||
borderColor: theme.palette.primary.main
|
||||
},
|
||||
border: `1px solid ${fade(theme.palette.text.secondary, 0.4)}`,
|
||||
borderRadius: 4,
|
||||
boxShadow: `inset 0 0 0 0 ${theme.palette.primary.main}`,
|
||||
fontSize: theme.typography.body1.fontSize,
|
||||
minHeight: 56,
|
||||
padding: theme.spacing(3, 2),
|
||||
paddingBottom: theme.spacing(),
|
||||
paddingLeft: 10,
|
||||
position: "relative",
|
||||
transition: theme.transitions.duration.short + "ms"
|
||||
},
|
||||
rootActive: {
|
||||
boxShadow: `inset 0px 0px 0 2px ${theme.palette.primary.main}`
|
||||
},
|
||||
rootDisabled: {
|
||||
...theme.overrides.MuiOutlinedInput.root["&$disabled"]["& fieldset"]
|
||||
},
|
||||
rootError: {
|
||||
borderColor: theme.palette.error.main
|
||||
}
|
||||
};
|
||||
},
|
||||
{ name: "RichTextEditor" }
|
||||
);
|
||||
|
||||
const RichTextEditor: React.FC<RichTextEditorProps> = ({
|
||||
data,
|
||||
disabled,
|
||||
|
@ -158,25 +45,7 @@ const RichTextEditor: React.FC<RichTextEditorProps> = ({
|
|||
},
|
||||
onReady,
|
||||
readOnly: disabled,
|
||||
tools: {
|
||||
header: {
|
||||
class: Header,
|
||||
config: {
|
||||
defaultLevel: 1,
|
||||
levels: [1, 2, 3]
|
||||
}
|
||||
},
|
||||
list: List,
|
||||
quote: Quote,
|
||||
strikethrough: createGenericInlineTool({
|
||||
sanitize: {
|
||||
s: {}
|
||||
},
|
||||
shortcut: "CMD+S",
|
||||
tagName: "s",
|
||||
toolboxIcon: strikethroughIcon
|
||||
})
|
||||
}
|
||||
tools
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -205,7 +74,7 @@ const RichTextEditor: React.FC<RichTextEditorProps> = ({
|
|||
{label}
|
||||
</InputLabel>
|
||||
<div
|
||||
className={classNames(classes.root, {
|
||||
className={classNames(classes.editor, classes.root, {
|
||||
[classes.rootActive]: isFocused,
|
||||
[classes.rootDisabled]: disabled,
|
||||
[classes.rootError]: error
|
||||
|
|
79
src/components/RichTextEditor/RichTextEditorContent.tsx
Normal file
79
src/components/RichTextEditor/RichTextEditorContent.tsx
Normal file
|
@ -0,0 +1,79 @@
|
|||
import EditorJS, {
|
||||
OutputData,
|
||||
ToolConstructable,
|
||||
ToolSettings
|
||||
} from "@editorjs/editorjs";
|
||||
import Header from "@editorjs/header";
|
||||
import List from "@editorjs/list";
|
||||
import Quote from "@editorjs/quote";
|
||||
import strikethroughIcon from "@saleor/icons/StrikethroughIcon";
|
||||
import classNames from "classnames";
|
||||
import createGenericInlineTool from "editorjs-inline-tool";
|
||||
import React from "react";
|
||||
|
||||
import useStyles from "./styles";
|
||||
|
||||
export interface RichTextEditorContentProps {
|
||||
className?: string;
|
||||
data: OutputData;
|
||||
onReady?: () => void;
|
||||
}
|
||||
|
||||
export const tools: Record<string, ToolConstructable | ToolSettings> = {
|
||||
header: {
|
||||
class: Header,
|
||||
config: {
|
||||
defaultLevel: 1,
|
||||
levels: [1, 2, 3]
|
||||
}
|
||||
},
|
||||
list: List,
|
||||
quote: Quote,
|
||||
strikethrough: createGenericInlineTool({
|
||||
sanitize: {
|
||||
s: {}
|
||||
},
|
||||
shortcut: "CMD+S",
|
||||
tagName: "s",
|
||||
toolboxIcon: strikethroughIcon
|
||||
})
|
||||
};
|
||||
|
||||
const RichTextEditorContent: React.FC<RichTextEditorContentProps> = ({
|
||||
className,
|
||||
data,
|
||||
onReady
|
||||
}) => {
|
||||
const classes = useStyles({});
|
||||
|
||||
const editor = React.useRef<EditorJS>();
|
||||
const editorContainer = React.useRef<HTMLDivElement>();
|
||||
React.useEffect(
|
||||
() => {
|
||||
if (data) {
|
||||
editor.current = new EditorJS({
|
||||
data,
|
||||
holder: editorContainer.current,
|
||||
onReady,
|
||||
readOnly: true,
|
||||
tools
|
||||
});
|
||||
}
|
||||
|
||||
return editor.current?.destroy;
|
||||
},
|
||||
// Rerender editor only if changed from undefined to defined state
|
||||
[data === undefined]
|
||||
);
|
||||
React.useEffect(() => editor.current?.destroy, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(classes.editor, className)}
|
||||
ref={editorContainer}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
RichTextEditorContent.displayName = "RichTextEditorContent";
|
||||
export default RichTextEditorContent;
|
112
src/components/RichTextEditor/styles.ts
Normal file
112
src/components/RichTextEditor/styles.ts
Normal file
|
@ -0,0 +1,112 @@
|
|||
import { makeStyles } from "@material-ui/core/styles";
|
||||
import { fade } from "@material-ui/core/styles/colorManipulator";
|
||||
|
||||
const useStyles = makeStyles(
|
||||
theme => {
|
||||
const hover = {
|
||||
"&:hover": {
|
||||
background: fade(theme.palette.primary.main, 0.1)
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
editor: {
|
||||
"& .cdx-quote__text": {
|
||||
minHeight: 24
|
||||
},
|
||||
"& .ce-block--selected .ce-block__content": {
|
||||
background: `${fade(theme.palette.primary.main, 0.2)} !important`
|
||||
},
|
||||
"& .ce-block__content": {
|
||||
margin: 0,
|
||||
maxWidth: "unset"
|
||||
},
|
||||
"& .ce-conversion-tool": {
|
||||
...hover
|
||||
},
|
||||
"& .ce-conversion-tool--focused": {
|
||||
background: `${fade(theme.palette.primary.main, 0.1)} !important`
|
||||
},
|
||||
"& .ce-conversion-tool__icon": {
|
||||
background: "none"
|
||||
},
|
||||
"& .ce-conversion-toolbar": {
|
||||
background: theme.palette.background.paper
|
||||
},
|
||||
"& .ce-header": {
|
||||
marginBottom: 0,
|
||||
paddingBottom: theme.spacing(1)
|
||||
},
|
||||
"& .ce-inline-tool": {
|
||||
...hover,
|
||||
color: theme.palette.text.primary,
|
||||
height: 32,
|
||||
transition: theme.transitions.duration.short + "ms",
|
||||
width: 32
|
||||
},
|
||||
"& .ce-inline-toolbar": {
|
||||
"& input": {
|
||||
background: "none"
|
||||
},
|
||||
background: theme.palette.background.paper,
|
||||
color: theme.palette.text.primary
|
||||
},
|
||||
"& .ce-inline-toolbar__dropdown": {
|
||||
...hover,
|
||||
height: 32,
|
||||
marginRight: 0
|
||||
},
|
||||
"& .ce-inline-toolbar__toggler-and-button-wrapper": {
|
||||
paddingRight: 0
|
||||
},
|
||||
"& .ce-toolbar__actions": {
|
||||
right: 0,
|
||||
top: 0
|
||||
},
|
||||
"& .ce-toolbar__content": {
|
||||
maxWidth: "unset"
|
||||
},
|
||||
"& .ce-toolbar__plus": {
|
||||
left: -9
|
||||
},
|
||||
"& .ce-toolbox.ce-toolbox--opened": {
|
||||
left: 16
|
||||
},
|
||||
"& .codex-editor__redactor": {
|
||||
marginRight: `${theme.spacing(4)}px !important`,
|
||||
paddingBottom: "0 !important"
|
||||
},
|
||||
"& a": {
|
||||
color: theme.palette.primary.light
|
||||
},
|
||||
"&:not($rootDisabled):hover": {
|
||||
borderColor: theme.palette.primary.main
|
||||
}
|
||||
},
|
||||
root: {
|
||||
border: `1px solid ${fade(theme.palette.text.secondary, 0.4)}`,
|
||||
borderRadius: 4,
|
||||
boxShadow: `inset 0 0 0 0 ${theme.palette.primary.main}`,
|
||||
fontSize: theme.typography.body1.fontSize,
|
||||
minHeight: 56,
|
||||
padding: theme.spacing(3, 2),
|
||||
paddingBottom: theme.spacing(),
|
||||
paddingLeft: 10,
|
||||
position: "relative",
|
||||
transition: theme.transitions.duration.short + "ms"
|
||||
},
|
||||
rootActive: {
|
||||
boxShadow: `inset 0px 0px 0 2px ${theme.palette.primary.main}`
|
||||
},
|
||||
rootDisabled: {
|
||||
...theme.overrides.MuiOutlinedInput.root["&$disabled"]["& fieldset"]
|
||||
},
|
||||
rootError: {
|
||||
borderColor: theme.palette.error.main
|
||||
}
|
||||
};
|
||||
},
|
||||
{ name: "RichTextEditor" }
|
||||
);
|
||||
|
||||
export default useStyles;
|
|
@ -1,9 +1,9 @@
|
|||
import { PageData } from "@saleor/pages/components/PageDetailsPage/form";
|
||||
import { PageErrorCode } from "@saleor/types/globalTypes";
|
||||
import { storiesOf } from "@storybook/react";
|
||||
import React from "react";
|
||||
|
||||
import PageDetailsPage, {
|
||||
PageDetailsPageFormData,
|
||||
PageDetailsPageProps
|
||||
} from "../../../pages/components/PageDetailsPage";
|
||||
import { page } from "../../../pages/fixtures";
|
||||
|
@ -36,7 +36,7 @@ storiesOf("Views / Pages / Page details", module)
|
|||
"isPublished",
|
||||
"seoDescription",
|
||||
"seoTitle"
|
||||
] as Array<keyof PageDetailsPageFormData>).map(field => ({
|
||||
] as Array<keyof PageData>).map(field => ({
|
||||
__typename: "PageError",
|
||||
code: PageErrorCode.INVALID,
|
||||
field
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { OutputData } from "@editorjs/editorjs";
|
||||
import Button from "@material-ui/core/Button";
|
||||
import Card from "@material-ui/core/Card";
|
||||
import CardContent from "@material-ui/core/CardContent";
|
||||
|
@ -36,7 +37,7 @@ export interface TranslationFieldsProps {
|
|||
saveButtonState: ConfirmButtonTransitionState;
|
||||
onEdit: (field: string) => void;
|
||||
onDiscard: () => void;
|
||||
onSubmit: (field: string, data: string) => void;
|
||||
onSubmit: (field: string, data: string | OutputData) => void;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles(
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { OutputData } from "@editorjs/editorjs";
|
||||
import Typography from "@material-ui/core/Typography";
|
||||
import { ConfirmButtonTransitionState } from "@saleor/components/ConfirmButton";
|
||||
import DraftRenderer from "@saleor/components/DraftRenderer";
|
||||
import Form from "@saleor/components/Form";
|
||||
import RichTextEditor from "@saleor/components/RichTextEditor";
|
||||
import RichTextEditorContent from "@saleor/components/RichTextEditor/RichTextEditorContent";
|
||||
import useRichText from "@saleor/utils/richText/useRichText";
|
||||
import React from "react";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
|
||||
|
@ -14,7 +15,7 @@ interface TranslationFieldsRichProps {
|
|||
initial: string;
|
||||
saveButtonState: ConfirmButtonTransitionState;
|
||||
onDiscard: () => void;
|
||||
onSubmit: (data: string) => void;
|
||||
onSubmit: (data: OutputData) => void;
|
||||
}
|
||||
|
||||
const TranslationFieldsRich: React.FC<TranslationFieldsRichProps> = ({
|
||||
|
@ -26,40 +27,39 @@ const TranslationFieldsRich: React.FC<TranslationFieldsRichProps> = ({
|
|||
onSubmit
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const [content, change] = useRichText({
|
||||
initial,
|
||||
triggerChange: () => undefined
|
||||
});
|
||||
|
||||
const submit = () => onSubmit(content.current);
|
||||
|
||||
return edit ? (
|
||||
<Form
|
||||
initial={{ translation: initial }}
|
||||
onSubmit={data => onSubmit(data.translation)}
|
||||
>
|
||||
{({ change, submit }) => (
|
||||
<div>
|
||||
<RichTextEditor
|
||||
disabled={disabled}
|
||||
error={undefined}
|
||||
helperText={undefined}
|
||||
initial={JSON.parse(initial)}
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: "Translation"
|
||||
})}
|
||||
name="translation"
|
||||
onChange={change}
|
||||
/>
|
||||
<TranslationFieldsSave
|
||||
saveButtonState={saveButtonState}
|
||||
onDiscard={onDiscard}
|
||||
onSave={submit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
<form onSubmit={submit}>
|
||||
<RichTextEditor
|
||||
data={content.current}
|
||||
disabled={disabled}
|
||||
error={undefined}
|
||||
helperText={undefined}
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: "Translation"
|
||||
})}
|
||||
name="translation"
|
||||
onChange={change}
|
||||
/>
|
||||
<TranslationFieldsSave
|
||||
saveButtonState={saveButtonState}
|
||||
onDiscard={onDiscard}
|
||||
onSave={submit}
|
||||
/>
|
||||
</form>
|
||||
) : initial === null ? (
|
||||
<Typography color="textSecondary">
|
||||
<FormattedMessage defaultMessage="No translation yet" />
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography>
|
||||
<DraftRenderer content={JSON.parse(initial)} />
|
||||
<RichTextEditorContent data={JSON.parse(initial)} />
|
||||
</Typography>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -43,7 +43,7 @@ const TranslationFieldsSave: React.FC<TranslationFieldsSaveProps> = props => {
|
|||
>
|
||||
<FormattedMessage {...buttonMessages.save} />
|
||||
</ConfirmButton>
|
||||
<Button onClick={onDiscard}>
|
||||
<Button onClick={onDiscard} type="submit">
|
||||
<FormattedMessage defaultMessage="Discard" description="button" />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { OutputData } from "@editorjs/editorjs";
|
||||
import { ConfirmButtonTransitionState } from "@saleor/components/ConfirmButton";
|
||||
import { ShopInfo_shop_languages } from "@saleor/components/Shop/types/ShopInfo";
|
||||
|
||||
|
@ -11,5 +12,5 @@ export interface TranslationsEntitiesPageProps {
|
|||
onEdit: (field: string) => void;
|
||||
onDiscard: () => void;
|
||||
onLanguageChange: (lang: string) => void;
|
||||
onSubmit: (field: string, data: string) => void;
|
||||
onSubmit: (field: string, data: string | OutputData) => void;
|
||||
}
|
||||
|
|
|
@ -11,11 +11,13 @@ function useRichText(opts: {
|
|||
);
|
||||
const [, setLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
try {
|
||||
data.current = JSON.parse(opts.initial);
|
||||
setLoaded(true);
|
||||
} catch {
|
||||
data.current = undefined;
|
||||
if (opts.initial !== null) {
|
||||
try {
|
||||
data.current = JSON.parse(opts.initial);
|
||||
setLoaded(true);
|
||||
} catch {
|
||||
data.current = undefined;
|
||||
}
|
||||
}
|
||||
}, [opts.initial]);
|
||||
|
||||
|
|
Loading…
Reference in a new issue