diff --git a/package.json b/package.json index e76ca30..4ccdea1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mui-rte", - "version": "1.0.12", + "version": "1.0.13", "description": "Material-UI Rich Text Editor and Viewer", "keywords": [ "material-ui", diff --git a/rollup.config.js b/rollup.config.js index 4d9685e..6867489 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -10,11 +10,12 @@ export default { include: ['node_modules/**'], extensions: ['.js', '.ts'] }), - uglify() + process.env.NODE_ENV === "production" && uglify() ], output: { file: 'dist/index.js', format: 'cjs', exports: 'named', + sourcemap: process.env.NODE_ENV === "development" } }; \ No newline at end of file diff --git a/src/MUIRichTextEditor.tsx b/src/MUIRichTextEditor.tsx index 4d316e8..d1557b2 100644 --- a/src/MUIRichTextEditor.tsx +++ b/src/MUIRichTextEditor.tsx @@ -2,25 +2,28 @@ import * as React from 'react' import Immutable from 'immutable' import classNames from 'classnames' import { createStyles, withStyles, WithStyles, Theme } from '@material-ui/core/styles' -import SaveIcon from '@material-ui/icons/Save' -import FormatClearIcon from '@material-ui/icons/FormatClear' import { - Editor, EditorState, convertFromRaw, RichUtils, - CompositeDecorator, convertToRaw, DefaultDraftBlockRenderMap + Editor, EditorState, convertFromRaw, RichUtils, AtomicBlockUtils, + CompositeDecorator, convertToRaw, DefaultDraftBlockRenderMap, DraftEditorCommand, + DraftHandleValue, + ContentBlock } from 'draft-js' import EditorControls, { TEditorControl } from './components/EditorControls' -import EditorButton from './components/EditorButton' import Link from './components/Link' -import LinkPopover from './components/LinkPopover' +import Image from './components/Image' import Blockquote from './components/Blockquote' import CodeBlock from './components/CodeBlock' +import UrlPopover from './components/UrlPopover' import { getSelectionInfo, getCompatibleSpacing } from './utils' const styles = ({ spacing, typography, palette }: Theme) => createStyles({ root: { margin: getCompatibleSpacing(spacing, 1, 0, 0, 0), fontFamily: typography.body1.fontFamily, - fontSize: typography.body1.fontSize + fontSize: typography.body1.fontSize, + '& figure': { + margin: 0 + } }, inheritFontSize: { fontSize: "inherit" @@ -70,6 +73,7 @@ type IMUIRichTextEditorState = { editorState: EditorState focused: boolean anchorLinkPopover?: HTMLElement + anchorMediaPopover?: HTMLElement urlValue?: string urlKey?: string } @@ -92,7 +96,7 @@ class MUIRichTextEditor extends React.Component { + const newState = RichUtils.handleKeyCommand(editorState, command) + if (newState) { + this.handleChange(newState) + return "handled" + } + return "not-handled" + } + save = () => { if (this.props.onSave) { this.props.onSave(JSON.stringify(convertToRaw(this.state.editorState.getCurrentContent()))) @@ -204,7 +217,7 @@ class MUIRichTextEditor extends React.Component { const { editorState } = this.state const selection = editorState.getSelection() - this.updateStateForLink(RichUtils.toggleLink(editorState, selection, null)) + this.updateStateForPopover(RichUtils.toggleLink(editorState, selection, null)) } confirmLink = (url?: string) => { @@ -245,13 +258,74 @@ class MUIRichTextEditor extends React.Component { + const { editorState } = this.state + let url = '' + let urlKey = undefined + const selectionInfo = getSelectionInfo(editorState) + const contentState = editorState.getCurrentContent() + const linkKey = selectionInfo.linkKey + + if (linkKey) { + const linkInstance = contentState.getEntity(linkKey) + url = linkInstance.getData().url + urlKey = linkKey + } + + this.setState({ + urlValue: url, + urlKey: urlKey, + anchorMediaPopover: document.getElementById("mui-rte-image-control")! + }, () => { + setTimeout(() => document.getElementById("mui-rte-media-popover")!.focus(), 0) + }) + } + + confirmMedia = (url?: string) => { + const { editorState, urlKey } = this.state + if (!url) { + this.setState({ + anchorMediaPopover: undefined + }) + return + } + + const contentState = editorState.getCurrentContent() + let replaceEditorState = null + + if (urlKey) { + contentState.replaceEntityData(urlKey, { + url: url + }) + const newEditorState = EditorState.push(editorState, contentState, "apply-entity") + replaceEditorState = EditorState.forceSelection(newEditorState, newEditorState.getCurrentContent().getSelectionAfter()) + } + else { + const contentStateWithEntity = contentState.createEntity( + 'IMAGE', + 'IMMUTABLE', + { + url: url + } + ) + const entityKey = contentStateWithEntity.getLastCreatedEntityKey() + const newEditorStateRaw = EditorState.set(editorState, { currentContent: contentStateWithEntity}) + const newEditorState = AtomicBlockUtils.insertAtomicBlock( + newEditorStateRaw, + entityKey, ' ') + replaceEditorState = EditorState.forceSelection(newEditorState, newEditorState.getCurrentContent().getSelectionAfter()) + } + this.updateStateForPopover(replaceEditorState) } - updateStateForLink = (editorState: EditorState) => { + updateStateForPopover = (editorState: EditorState) => { this.setState({ editorState: editorState, anchorLinkPopover: undefined, + anchorMediaPopover: undefined, urlValue: undefined, urlKey: undefined }, () => { @@ -272,6 +346,25 @@ class MUIRichTextEditor extends React.Component { + const blockType = contentBlock.getType() + if (blockType === 'atomic') { + const contentState = this.state.editorState.getCurrentContent() + const entity = contentBlock.getEntityAt(0) + if (!entity) { + return null + } + const type = contentState.getEntity(entity).getType() + if (type === 'IMAGE') { + return { + component: Image, + editable: false + } + } + return null + } + } + render() { const { classes, controls } = this.props const contentState = this.state.editorState.getCurrentContent() @@ -302,25 +395,11 @@ class MUIRichTextEditor extends React.Component - {this.props.controls === undefined || this.props.controls.includes("clear") ? - } - /> - : null } - {this.props.controls === undefined || this.props.controls.includes("save") ? - } - /> - : null } - + /> : null} {placeholder}
{this.state.anchorLinkPopover ? - : null} + {this.state.anchorMediaPopover ? + + : null} ) } diff --git a/src/components/EditorControls.tsx b/src/components/EditorControls.tsx index 2981496..1b91131 100644 --- a/src/components/EditorControls.tsx +++ b/src/components/EditorControls.tsx @@ -1,14 +1,17 @@ import * as React from 'react' +import { EditorState } from 'draft-js' import FormatBoldIcon from '@material-ui/icons/FormatBold' import FormatItalicIcon from '@material-ui/icons/FormatItalic' import FormatUnderlinedIcon from '@material-ui/icons/FormatUnderlined' import TitleIcon from '@material-ui/icons/Title' import InsertLinkIcon from '@material-ui/icons/InsertLink' +import InsertPhotoIcon from '@material-ui/icons/InsertPhoto' import FormatListNumberedIcon from '@material-ui/icons/FormatListNumbered' import FormatListBulletedIcon from '@material-ui/icons/FormatListBulleted' import FormatQuoteIcon from '@material-ui/icons/FormatQuote' import CodeIcon from '@material-ui/icons/Code' -import { EditorState } from 'draft-js' +import FormatClearIcon from '@material-ui/icons/FormatClear' +import SaveIcon from '@material-ui/icons/Save' import EditorButton from './EditorButton' import { getSelectionInfo } from '../utils' @@ -16,7 +19,9 @@ type KeyString = { [key: string]: React.ReactNode | EditorState } -export type TEditorControl = "title" | "bold" | "italic" | "underline" | "link" | "numberList" | "bulletList" | "quote" | "code" | "clear" | "save" +export type TEditorControl = + "title" | "bold" | "italic" | "underline" | "link" | "numberList" | + "bulletList" | "quote" | "code" | "clear" | "save" | "image" type TStyleType = { id?: string @@ -24,7 +29,7 @@ type TStyleType = { label: string style: string icon: JSX.Element - type: "inline" | "block" | "decorator" + type: "inline" | "block" | "callback" active?: boolean clickFnName?: string } @@ -63,10 +68,19 @@ const STYLE_TYPES: TStyleType[] = [ name: "link", style: 'LINK', icon: , - type: "decorator", + type: "callback", clickFnName: "onPromptLink", id: "mui-rte-link-control" }, + { + label: 'Image', + name: "image", + style: 'IMAGE', + icon: , + clickFnName: "onPromptMedia", + type: "callback", + id: "mui-rte-image-control" + }, { label: 'OL', name: "bulletList", @@ -94,6 +108,22 @@ const STYLE_TYPES: TStyleType[] = [ style: 'code-block', icon: , type: "block" + }, + { + label: 'Clear', + name: "clear", + style: 'clear', + icon: , + type: "callback", + clickFnName: "onClear" + }, + { + label: 'Save', + name: "save", + style: 'save', + icon: , + type: "callback", + clickFnName: "onSave" } ] @@ -104,14 +134,22 @@ interface IBlockStyleControlsProps extends KeyString { onToggleInline: (inlineStyle: any) => void onToggleBlock: (blockType: any) => void onPromptLink: () => void + onPromptMedia: () => void + onClear: () => void + onSave: () => void } const EditorControls: React.FC = (props: IBlockStyleControlsProps) => { const selectionInfo = getSelectionInfo(props.editorState) let filteredControls = STYLE_TYPES if (props.controls) { - filteredControls = STYLE_TYPES.filter(style => { - return props.controls!.includes(style.name) + filteredControls = [] + + props.controls!.forEach(name => { + const style = STYLE_TYPES.find(style => style.name === name) + if (style) { + filteredControls.push(style) + } }) } return ( @@ -144,7 +182,6 @@ const EditorControls: React.FC = (props: IBlockStyleCo /> ) })} - {props.children} ) } diff --git a/src/components/Image.tsx b/src/components/Image.tsx new file mode 100644 index 0000000..dcce327 --- /dev/null +++ b/src/components/Image.tsx @@ -0,0 +1,23 @@ +import * as React from 'react' +import { ContentState, ContentBlock } from 'draft-js' + +import { createStyles, withStyles, WithStyles } from '@material-ui/core/styles' + +const styles = () => createStyles({ + root: { + } +}) + +interface IImageProps extends WithStyles { + block: ContentBlock + contentState: ContentState +} + +const Image: React.FC = (props: IImageProps) => { + const { url } = props.contentState.getEntity(props.block.getEntityAt(0)).getData() + return ( + + ) +} + +export default withStyles(styles, { withTheme: true })(Image) \ No newline at end of file diff --git a/src/components/LinkPopover.tsx b/src/components/UrlPopover.tsx similarity index 84% rename from src/components/LinkPopover.tsx rename to src/components/UrlPopover.tsx index a4fd040..f751046 100644 --- a/src/components/LinkPopover.tsx +++ b/src/components/UrlPopover.tsx @@ -13,24 +13,25 @@ const styles = ({spacing}: Theme) => createStyles({ } }) -interface ILinkPopoverStateProps extends WithStyles { +interface IUrlPopoverStateProps extends WithStyles { + id: string url?: string anchor?: HTMLElement onConfirm: (url?: string) => void } -type TLinkPopoverState = { +type TUrlPopoverState = { urlError: boolean urlValue?: string } -const LinkPopover: React.FC = (props) => { +const UrlPopover: React.FC = (props) => { - const [state, setState] = useState({ + const [state, setState] = useState({ urlError: false, urlValue: props.url }) - const {classes} = props + const {classes, id} = props return ( = (props) => { {setState({...state, urlValue: event.target.value})}} placeholder="URL" @@ -72,4 +73,4 @@ const LinkPopover: React.FC = (props) => { ) } -export default withStyles(styles, { withTheme: true })(LinkPopover) \ No newline at end of file +export default withStyles(styles, { withTheme: true })(UrlPopover) \ No newline at end of file diff --git a/webpack.config.js b/webpack.config.js index 12a02ff..d5df09a 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -48,6 +48,7 @@ module.exports = { hot: true, contentBase: path.join(__dirname, "examples"), port: 9000, - watchContentBase: true + watchContentBase: true, + host: "0.0.0.0" } }; \ No newline at end of file