diff --git a/app/components/elements/ReplyEditor.jsx b/app/components/elements/ReplyEditor.jsx index 88c74bdfd776a7e003cce344d8466446e320b41b..4aa5308e73cd9ead5350199f31ae3f5659b10606 100644 --- a/app/components/elements/ReplyEditor.jsx +++ b/app/components/elements/ReplyEditor.jsx @@ -117,8 +117,9 @@ class ReplyEditor extends React.Component { const {onCancel, resetForm} = this.props resetForm() this.setAutoVote() - this.setState({rte_value: stateFromHtml()}) - this.refs.rte.reset() + //this.setState({rte_value: stateFromHtml()}) + //this.refs.rte.reset() + this.refs.rte.setState({state: stateFromHtml()}) if(onCancel) onCancel(e) } this.onChange = this.onChange.bind(this); @@ -215,6 +216,7 @@ class ReplyEditor extends React.Component { // As rte_editor is updated, keep the (invisible) 'body' field in sync. onChange(rte_value) { //this.setState({rte_value}) + this.refs.rte.setState({state: rte_value}) const html = stateToHtml(rte_value) const body = this.props.fields.body if(body.value !== html) body.onChange(html); diff --git a/app/components/elements/SlateEditor.jsx b/app/components/elements/SlateEditor.jsx index 0666a0441fbe1e0c425bdc609d32aa2490dc05cf..b2a968459a4a53b1f5a0f2835fbe6d31506745f6 100644 --- a/app/components/elements/SlateEditor.jsx +++ b/app/components/elements/SlateEditor.jsx @@ -26,7 +26,7 @@ if(process.env.BROWSER) { plugins.push( InsertImages({ - extensions: ['jpeg'], + extensions: ['jpeg', 'png', 'gif'], applyTransform: (transform, file) => { return transform.insertInline({ type: 'image', @@ -56,22 +56,29 @@ export default class SlateEditor extends React.Component { componentDidMount = () => { this.updateMenu() + this.updateSidebar() } componentDidUpdate = () => { this.updateMenu() + this.updateSidebar() } onChange = (state) => { - this.setState({ state }) + //this.setState({ state }) this.props.onChange(state) } // When the portal opens, cache the menu element. - onOpen = (portal) => { + onMenuOpen = (portal) => { this.setState({ menu: portal.firstChild }) } + // When the portal opens, cache the menu element. + onSidebarOpen = (portal) => { + this.setState({ sidebar: portal.firstChild }) + } + // Check if the current selection has a mark with `type` in it. hasMark = (type) => { @@ -295,7 +302,7 @@ console.log(JSON.stringify(Raw.serialize(state, {terse: false}), null, 2)) const { startBlock, startOffset, endOffset } = state // Allow soft returns for certain block types - if (startBlock.type == 'code-block' || startBlock.type == 'block-quote') { + if (startBlock.type == 'paragraph' || startBlock.type == 'code-block' || startBlock.type == 'block-quote') { let transform = state.transform() if (state.isExpanded) transform = transform.delete() transform = transform.insertText('\n') @@ -344,25 +351,82 @@ console.log(JSON.stringify(Raw.serialize(state, {terse: false}), null, 2)) return ( <div> {this.renderMenu()} + {this.renderSidebar()} {this.renderEditor()} </div> ) } + renderSidebar = () => { + const { state } = this.state + const isOpen = state.isExpanded && state.isFocused + return ( + <Portal isOpened onOpen={this.onSidebarOpen}> + <div className="SlateEditor__sidebar"> + {this.renderAddBlockButton({type: 'image', label: <Icon name="photo" />, handler: this.onClickInsertImage})} + {this.renderAddBlockButton({type: 'hrule', label: <Icon name="line" />, handler: this.onClickInsertHr})} + </div> + </Portal> + ) + } + + onClickInsertImage = (e) => { + e.preventDefault() + let { state } = this.state + + const src = window.prompt('Enter the URL of the image:', 'https://lh3.googleusercontent.com/-uY1D2XxBC5I/VbZbodsihNI/AAAAAAAAICw/glsw_avviBY/w592-h330/xkcdinternet.png') + if(!src) return; + + state = state + .transform() + //.insertBlock({type: 'paragraph', isVoid: false}) + .insertInline({type: 'image', isVoid: true, data: {src}}) + .collapseToStartOfNextBlock() + .apply() + + this.setState({ state }) + } + + onClickInsertHr = (e, type) => { + e.preventDefault() + let { state } = this.state + + state = state + .transform() + .insertBlock({type: 'hr', isVoid: true}) + .insertBlock({type: 'paragraph', isVoid: false}) + .apply() + + this.setState({ state }) + } + + + + renderAddBlockButton = (props) => { + const { type, label, handler } = props + const onMouseDown = e => handler(e) + + return ( + <span key={type} className="SlateEditor__sidebar-button" onMouseDown={onMouseDown}> + {label} + </span> + ) + } + renderMenu = () => { const { state } = this.state const isOpen = state.isExpanded && state.isFocused return ( - <Portal isOpened onOpen={this.onOpen}> + <Portal isOpened onOpen={this.onMenuOpen}> <div className="SlateEditor__menu SlateEditor__menu"> {schema.toolbarMarks.map(this.renderMarkButton)} {this.renderInlineButton({type: 'link', label: <Icon name="link" />})} {this.renderBlockButton({type: 'block-quote', label: <span>“</span>})} {this.renderBlockButton({type: 'heading-one', label: 'H1'})} {this.renderBlockButton({type: 'heading-two', label: 'H2'})} - {this.renderBlockButton({type: 'bulleted-list', label: 'ul'})} - {this.renderBlockButton({type: 'numbered-list', label: 'ol'})} + {/*this.renderBlockButton({type: 'bulleted-list', label: 'ul'})*/} + {/*this.renderBlockButton({type: 'numbered-list', label: 'ol'})*/} {this.renderBlockButton({type: 'code-block', label: '<>'})} </div> </Portal> @@ -420,6 +484,42 @@ console.log(JSON.stringify(Raw.serialize(state, {terse: false}), null, 2)) ) } + findParentTag = (el, tag, depth = 0) => { + if (!el) return null; + if (el.tagName == tag) return el; + return this.findParentTag(el.parentNode, tag, depth + 1); + } + + getCollapsedClientRect = () => { + const selection = document.getSelection(); + if (selection.rangeCount === 0 || !selection.getRangeAt || !selection.getRangeAt(0) || !selection.getRangeAt(0).startContainer || !selection.getRangeAt(0).startContainer.getBoundingClientRect) { + return null; + } + + const node = selection.getRangeAt(0).startContainer; + if(! this.findParentTag(node, 'P')) return; // only show sidebar at the beginning of an empty <p> + + const rect = node.getBoundingClientRect(); + return rect; + } + + updateSidebar = () => { + const { sidebar, state } = this.state + if (!sidebar) return + + const rect = this.getCollapsedClientRect() //position() + + if (state.isBlurred || state.isExpanded || !rect) { + sidebar.removeAttribute('style') + return + } + + //sidebar.style.opacity = 1 + sidebar.style.top = `${rect.top + window.scrollY}px` + //sidebar.style.top = `${rect.top + window.scrollY - sidebar.offsetHeight / 2 + rect.height / 2}px` + sidebar.style.left = `${rect.left + window.scrollX - sidebar.offsetWidth}px` + } + updateMenu = () => { const { menu, state } = this.state if (!menu) return @@ -430,7 +530,7 @@ console.log(JSON.stringify(Raw.serialize(state, {terse: false}), null, 2)) } const rect = position() - menu.style.opacity = 1 + //menu.style.opacity = 1 menu.style.top = `${rect.top + window.scrollY - menu.offsetHeight}px` menu.style.left = `${rect.left + window.scrollX - menu.offsetWidth / 2 + rect.width / 2}px` } diff --git a/app/components/elements/SlateEditor.scss b/app/components/elements/SlateEditor.scss index 9f616277d4d42a448bb50bb55f6a8d128fdfa787..e8ffdd07e77ddfb0136dee8181c80844200f71bc 100644 --- a/app/components/elements/SlateEditor.scss +++ b/app/components/elements/SlateEditor.scss @@ -1,45 +1,99 @@ -.SlateEditor { - //padding: 1rem; - //border: 1px solid $medium-gray; - //border-radius: 8px; -} - .SlateEditor.Markdown { a { border-bottom: 1px dotted #00f; position: relative; img {border: 1px dotted #00f} } - .active { - box-shadow: 0 0 0 2px blue; - } + .active {box-shadow: 0 0 0 2px blue;} + hr.active {box-shadow: 0 0 0 1px blue;} a:hover:after{ - border: 1px solid #ccc; - padding: 1px 3px; - background: #eee; - content: attr(href); - display: block; - position: absolute; - left: 0; - top: 110%; - line-height: 1; - white-space: nowrap; - font-size: 70%; - z-index: 99999; + border: 1px solid #ccc; + padding: 1px 3px; + background: #eee; + content: attr(href); + display: block; + position: absolute; + left: 0; + top: 110%; + line-height: 1; + white-space: nowrap; + font-size: 70%; + z-index: 99999; } } -.SlateEditor > * > * + * { - margin-top: 1em; + +.SlateEditor__sidebar { + opacity: 0.25; + font-size: 110%; + padding: 1px; + position: absolute; + z-index: 1; + top: -10000px; + left: -10000px; + margin-left: -10px; + background-color: #222; + border-radius: 4px; + transition: opacity .75s; + background-image: linear-gradient(180deg,#464646,#151515); + + &:hover {opacity: 1;} + &:after { + top: 0.6rem; + left: 100%; + border: transparent solid; + content: " "; + height: 0; + width: 0; + position: absolute; + pointer-events: none; + border-left-color: #464646; + border-width: 5px; + margin-left: 0px; + } } -.SlateEditor__menu > * { - display: inline-block; +.SlateEditor__sidebar-button { + display: block; + color: white; + padding: 1px 2px; + min-width: 1.75rem; + text-align: center; + svg {fill: white} + &:hover {svg {fill: #32cd32;}} } -.SlateEditor__menu > * + * { - margin: 0; + +.SlateEditor__menu { + font-size: 110%; + padding: 1px; + position: absolute; + z-index: 1; + top: -10000px; + left: -10000px; + margin-top: -6px; + opacity: 0.9; + background-color: #222; + border-radius: 4px; + transition: opacity .75s; + background-image: linear-gradient(180deg,#464646,#151515); + + &:hover {opacity: 1} + &:after { + top: 100%; + left: 50%; + border: solid transparent; + content: " "; + height: 0; + width: 0; + position: absolute; + pointer-events: none; + border-top-color: #151515; + border-width: 5px; + margin-left: -5px; + } + > * {display: inline-block;} } .SlateEditor__menu-button { @@ -53,14 +107,6 @@ background: rgba(0,0,0,0.1); border-radius: 2px; > span { - code { - border: none; - background: transparent; - color: inherit; - padding: 0; - font-size: 90%; - vertical-align: top; - } path {fill: white} } } @@ -74,50 +120,30 @@ } } -.SlateEditor__menu-button-sup span, -.SlateEditor__menu-button-sub span { - font-family: Arial; +.SlateEditor__menu-button-code > span > code { + border: none; + background: transparent; + color: inherit; + padding: 0; font-size: 90%; - vertical-align: 4%; + vertical-align: top; } -.SlateEditor__menu-button-link { - .Icon > svg, .Icon {width: 1rem; height: 1rem;} +.SlateEditor__menu-button-sup > span > span , +.SlateEditor__menu-button-sub > span > span { + font-family: Arial; + font-size: 80%; + vertical-align: 4%; } -.SlateEditor__menu-button-block-quote { - span { - font-size: 140%; - vertical-align: -25%; - line-height: 1; - } +.SlateEditor__menu-button-link { + .Icon, + .Icon > svg {width: 1rem; height: 1rem;} } -.SlateEditor__menu { - font-size: 110%; - padding: 1px; - position: absolute; - z-index: 1; - top: -10000px; - left: -10000px; - margin-top: -6px; - opacity: 0; - background-color: #222; - border-radius: 4px; - transition: opacity .75s; - background-image: linear-gradient(180deg,#464646,#151515); +.SlateEditor__menu-button-block-quote > span > span { + font-size: 220%; + vertical-align: -45%; + line-height: 1; } -.SlateEditor__menu:after { - top: 100%; - left: 50%; - border: solid transparent; - content: " "; - height: 0; - width: 0; - position: absolute; - pointer-events: none; - border-top-color: #151515; - border-width: 5px; - margin-left: -5px; -}