diff --git a/app/components/all.scss b/app/components/all.scss index 51773df2095d01598e019a208da91d919c6d1e4c..08a7e6905431431cba09936cd469be6467a727a6 100644 --- a/app/components/all.scss +++ b/app/components/all.scss @@ -15,6 +15,7 @@ @import "./elements/FormattedAsset"; @import "./elements/ReplyEditor"; @import "./elements/MediumEditor"; +@import "./elements/SlateEditor"; @import "./elements/DropdownMenu"; @import "./elements/FoundationDropdownMenu"; @import "./elements/VerticalMenu"; diff --git a/app/components/elements/SlateEditor.jsx b/app/components/elements/SlateEditor.jsx new file mode 100644 index 0000000000000000000000000000000000000000..b2cf2a6d603f0b96327fc09b949693cc829f440b --- /dev/null +++ b/app/components/elements/SlateEditor.jsx @@ -0,0 +1,299 @@ +/* +import EditBlockquote from 'slate-edit-blockquote' +import TrailingBlock from 'slate-trailing-block' + +const plugins = [ + TrailingBlock({ type: 'paragraph' }), + EditBlockquote() +] +*/ +const plugins = [] + +import { Editor, Mark, Raw, Html } from 'slate' +import Portal from 'react-portal' +import React from 'react' +import position from 'selection-position' + +const serializer = new Html({rules: [ + { + deserialize: (el, next) => null, + serialize: (object, children) => { + if(object.kind == 'string') return; + if(object.kind == 'block') { + switch(object.type) { + case 'paragraph': return <p>{children}</p> + case 'block-quote': return <blockquote>{children}</blockquote> + case 'bulleted-list': return <ul>{children}</ul> + case 'numbered-list': return <ol>{children}</ol> + case 'heading-one': return <h1>{children}</h1> + case 'heading-two': return <h2>{children}</h2> + case 'heading-three': return <h3>{children}</h3> + case 'heading-four': return <h4>{children}</h4> + case 'bulleted-list-item': return <li>{children}</li> + case 'numbered-list-item': return <li>{children}</li> + } + } + if(object.kind == 'mark') { + switch(object.type) { + case 'bold': return <strong>{children}</strong> + case 'italic': return <i>{children}</i> + case 'underline': return <u>{children}</u> + case 'strike': return <s>{children}</s> + case 'code': return <code>{children}</code> + } + } + + console.log("No serializer: ", object.kind, JSON.stringify(object, null, 2), children) + } + }, +]}) + +const schema = { + defaultNode: 'paragraph', + //blockTypes: { + // ...Blocks, + //}, + toolbarMarks: [ + { type: 'bold', icon: 'bold' }, + { type: 'italic', icon: 'italic' }, + { type: 'underline', icon: 'underline' }, + { type: 'code', icon: 'code' }, + ], + toolbarTypes: [ + { type: 'heading-one', icon: 'header' }, + { type: 'heading-two', icon: 'header' }, + { type: 'block-quote', icon: 'quote-left' }, + { type: 'numbered-list', icon: 'list-ol' }, + { type: 'bulleted-list', icon: 'list-ul' }, + ], + sidebarTypes: [], + nodes: { + 'block': ({ children }) => <p style={{background: 'red'}}>{children}</p>, + 'paragraph': ({ children }) => <p style={{color: 'blue'}}>{children}</p>, + 'block-quote': ({ children }) => <blockquote>{children}</blockquote>, + 'bulleted-list': ({ children }) => <ul>{children}</ul>, + 'numbered-list': ({ children, attributes }) => <ol {...attributes}>{children}</ol>, + 'heading-one': ({ children }) => <h1>{children}</h1>, + 'heading-two': ({ children }) => <h2>{children}</h2>, + 'heading-three': ({ children }) => <h3>{children}</h3>, + 'heading-four': ({ children }) => <h4>{children}</h4>, + 'bulleted-list-item': ({ children }) => <li>{children}</li>, + 'numbered-list-item': ({ children }) => <li>{children}</li>, + }, + marks: { + bold: props => <strong>{props.children}</strong>, + code: props => <code>{props.children}</code>, + italic: props => <em>{props.children}</em>, + underline: props => <u>{props.children}</u>, + strike: props => <s>{props.children}</s>, + }, + getMarkdownType: (chars) => { + switch (chars) { + case '*': + case '-': return 'bulleted-list-item'; + case '>': return 'block-quote'; + case '#': return 'heading-one'; + case '##': return 'heading-two'; + case '###': return 'heading-three'; + case '####': return 'heading-four'; + case '1.': return 'numbered-list-item'; + default: return null; + } + }, +} + + +class SlateEditor extends React.Component { + + constructor(props) { + super(props) + this.state = {state: props.initialState} + } + + componentDidMount = () => { + this.updateMenu() + } + + componentDidUpdate = () => { + this.updateMenu() + } + + // Check if the current selection has a mark with `type` in it. + hasMark = (type) => { + const { state } = this.state + return state.marks.some(mark => mark.type == type) + } + + onChange = (state) => { + this.setState({ state }) + } + + // When a mark button is clicked, toggle the current mark. + onClickMark = (e, type) => { + e.preventDefault() + let { state } = this.state + + state = state + .transform() + .toggleMark(type) + .apply() + + this.setState({ state }) + } + + // When the portal opens, cache the menu element. + onOpen = (portal) => { + this.setState({ menu: portal.firstChild }) + } + + + // Markdown-style quick formatting + onKeyDown = (e, data, state) => { + switch (data.key) { + case 'space': return this.onSpace(e, state) + case 'backspace': return this.onBackspace(e, state) + case 'enter': return this.onEnter(e, state) + } + } + + // If space was entered, check if it was a markdown sequence + onSpace = (e, state) => { + if (state.isExpanded) return + let { selection } = state + const { startText, startBlock, startOffset } = state + const chars = startBlock.text.slice(0, startOffset).replace(/\s*/g, '') + const type = schema.getMarkdownType(chars) + + if (!type) return + if (type == 'bulleted-list-item' && startBlock.type == 'bulleted-list-item') return + if (type == 'numbered-list-item' && startBlock.type == 'numbered-list-item') return + e.preventDefault() + + let transform = state + .transform() + .setBlock(type) + + if (type == 'bulleted-list-item') transform = transform.wrapBlock('bulleted-list') + if (type == 'numbered-list-item') transform = transform.wrapBlock('numbered-list') + + state = transform + .extendToStartOf(startBlock) + .delete() + .apply() + + return state + } + + // On backspace, if at the start of a non-paragraph, convert it back into a paragraph node. + onBackspace = (e, state) => { + if (state.isExpanded) return + if (state.startOffset != 0) return + const { startBlock } = state + + if (startBlock.type == 'paragraph') return + e.preventDefault() + + let transform = state + .transform() + .setBlock('paragraph') + + if (startBlock.type == 'bulleted-list-item') transform = transform.unwrapBlock('bulleted-list') + if (startBlock.type == 'numbered-list-item') transform = transform.unwrapBlock('numbered-list') + + state = transform.apply() + return state + } + + // On return, if at the end of a node type that should not be extended, create a new paragraph below it. + onEnter = (e, state) => { + if (state.isExpanded) return //menu open + const { startBlock, startOffset, endOffset } = state + if (startOffset == 0 && startBlock.length == 0) return this.onBackspace(e, state) //empty block + if (endOffset != startBlock.length) return //not at end of block + + if ( + startBlock.type != 'heading-one' && + startBlock.type != 'heading-two' && + startBlock.type != 'heading-three' && + startBlock.type != 'heading-four' && + startBlock.type != 'block-quote' + ) return + + e.preventDefault() + return state + .transform() + .splitBlock() + .setBlock('paragraph') + .apply() + } + + + render = () => { + const { state } = this.state + console.log(serializer.serialize(state)); + return ( + <div> + {this.renderMenu()} + {this.renderEditor()} + </div> + ) + } + + renderMenu = () => { + const { state } = this.state + const isOpen = state.isExpanded && state.isFocused + return ( + <Portal isOpened onOpen={this.onOpen}> + <div className="SlateEditor__menu SlateEditor__hover-menu"> + {this.renderMarkButton('bold', <strong>B</strong>)} + {this.renderMarkButton('italic', <i>I</i>)} + {this.renderMarkButton('underline', <u>U</u>)} + {this.renderMarkButton('strike', <s>S</s>)} + {this.renderMarkButton('code', <code>{'{}'}</code>)} + </div> + </Portal> + ) + } + + renderMarkButton = (type, label) => { + const isActive = this.hasMark(type) + const onMouseDown = e => this.onClickMark(e, type) + + return ( + <span className="SlateEditor__menu-button" onMouseDown={onMouseDown} data-active={isActive}> + <span>{label}</span> + </span> + ) + } + + renderEditor = () => { + return ( + <div className="SlateEditor"> + <Editor + schema={schema} + plugins={plugins} + state={this.state.state} + onChange={this.onChange} + onKeyDown={this.onKeyDown} + /> + </div> + ) + } + + updateMenu = () => { + const { menu, state } = this.state + if (!menu) return + + if (state.isBlurred || state.isCollapsed) { + menu.removeAttribute('style') + return + } + + const rect = position() + 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` + } +} + +export default SlateEditor diff --git a/app/components/elements/SlateEditor.scss b/app/components/elements/SlateEditor.scss new file mode 100644 index 0000000000000000000000000000000000000000..2458fbe7c91449b54dad30447d64a7485cc3f96e --- /dev/null +++ b/app/components/elements/SlateEditor.scss @@ -0,0 +1,74 @@ +.SlateEditor { + padding: 1rem; +} + +.SlateEditor > * > * + * { + margin-top: 1em; +} + +.SlateEditor__menu > * { + display: inline-block; +} + +.SlateEditor__menu > * + * { + margin-left: 10px; +} + +.SlateEditor__menu-button { + font-family: 'Georgia', serif; + color: #777; + cursor: pointer; + > span { + display: inline-block; + width: 2rem; + text-align: center; + background: #333; + border-radius: 2px; + code { + border: none; + background: transparent; + color: inherit; + } + } + > span:hover { + background: #555; + color: white; + } +} + +.SlateEditor__menu-button[data-active="true"] { + color: white; + > span { + background: #555; + } +} + +/* +.toolbar-menu { + padding: 1px 0 17px 18px; + margin: 0 -20px; + border-bottom: 2px solid #eee; + margin-bottom: 20px; +} +*/ + +.SlateEditor__hover-menu { + padding: 8px 7px 6px; + position: absolute; + z-index: 1; + top: -10000px; + left: -10000px; + margin-top: -6px; + opacity: 0; + background-color: #222; + border-radius: 4px; + transition: opacity .75s; +} + +.SlateEditor__hover-menu .button { + color: #aaa; +} + +.SlateEditor__hover-menu .button[data-active="true"] { + color: #fff; +} diff --git a/package.json b/package.json index 8a94a547d8a54c3631f8ea224a922107d516a0c2..06bd5a7c9426788a5bae722c772ee4141b7428f1 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "react-medium-editor": "^1.8.0", "react-notification": "^5.0.7", "react-overlays": "0.6.4", + "react-portal": "^2.2.1", "react-prop-types": "^0.3.0", "react-qr": "0.0.2", "react-rangeslider": "^1.0.3", @@ -108,9 +109,11 @@ "sanitize-html": "^1.11.4", "sass-loader": "^3.1.2", "secure-random": "^1.1.1", + "selection-position": "^1.0.0", "sendgrid": "^4.0.1", "sequelize": "^3.21.0", "sequelize-cli": "^2.3.1", + "slate": "^0.14.13", "speakingurl": "^9.0.0", "style-loader": "^0.13.0", "svg-inline-loader": "^0.4.0",