Use rich text editor in translations

This commit is contained in:
dominik-zeglen 2020-11-05 17:30:38 +01:00
parent 55d9a1c986
commit 02e93b06d6
9 changed files with 240 additions and 176 deletions

View file

@ -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

View 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;

View 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;

View file

@ -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

View file

@ -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(

View file

@ -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>
);
};

View file

@ -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>

View file

@ -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;
}

View file

@ -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]);