Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • hive/condenser
1 result
Show changes
Commits on Source (20)
Showing
with 1312 additions and 88 deletions
<svg fill="#000000" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="M7.77 6.76L6.23 5.48.82 12l5.41 6.52 1.54-1.28L3.42 12l4.35-5.24zM7 13h2v-2H7v2zm10-2h-2v2h2v-2zm-6 2h2v-2h-2v2zm6.77-7.52l-1.54 1.28L20.58 12l-4.35 5.24 1.54 1.28L23.18 12l-5.41-6.52z"/>
</svg>
\ No newline at end of file
<svg fill="#000000" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<path d="M24 24H0V0h24v24z" id="a"/>
</defs>
<clipPath id="b">
<use overflow="visible" xlink:href="#a"/>
</clipPath>
<path clip-path="url(#b)" d="M3 4V1h2v3h3v2H5v3H3V6H0V4h3zm3 6V7h3V4h7l1.83 2H21c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H5c-1.1 0-2-.9-2-2V10h3zm7 9c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm-3.2-5c0 1.77 1.43 3.2 3.2 3.2s3.2-1.43 3.2-3.2-1.43-3.2-3.2-3.2-3.2 1.43-3.2 3.2z"/>
</svg>
\ No newline at end of file
File mode changed from 100755 to 100644
File mode changed from 100755 to 100644
......@@ -77,27 +77,6 @@ label {
}
}
.vframe {
display: flex;
position: relative;
overflow: hidden;
backface-visibility: hidden;
flex-flow: column nowrap;
align-items: stretch;
justify-content: flex-start;
min-height: 90vh;
> .vframe__section {
flex-shrink: 0;
flex-grow: 1;
}
> .vframe__section--shrink {
flex-shrink: 1;
flex-grow: 0;
}
}
.marginLeft1rem {
margin-left: 1rem;
}
......
......@@ -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";
......
......@@ -28,6 +28,8 @@ const icons = [
'flag1',
'flag2',
'reblog',
'photo',
'line',
];
const icons_map = {};
for (const i of icons) icons_map[i] = require(`app/assets/icons/${i}.svg`);
......
......@@ -15,16 +15,16 @@ import {Set} from 'immutable'
import {cleanReduxInput} from 'app/utils/ReduxForms'
import Remarkable from 'remarkable'
import {serverApiRecordEvent} from 'app/utils/ServerApiClient';
import SlateEditor, {serializeHtml, deserializeHtml, getDemoState} from 'app/components/elements/SlateEditor'
const remarkable = new Remarkable({ html: true, linkify: false })
const RichTextEditor = process.env.BROWSER ? require('react-rte-image').default : null;
const RTE_DEFAULT = false
let saveEditorTimeout
// removes <html></html> wrapper if exists
function stripHtmlWrapper(text) {
const m = text.match(/<html>\n?([\S\s]+?)\n?<\/html>/m);
const m = text.match(/<html>\n*([\S\s]+?)?\n*<\/html>/m);
return m && m.length === 2 ? m[1] : text;
}
......@@ -45,17 +45,16 @@ const isHtmlTest = text =>
/^<p>[\S\s]*<\/p>/.test(text)
function stateToHtml(state) {
let html = state.toString('html');
let html = serializeHtml(state)
if (html === '<p></p>') html = '';
if (html === '<p><br></p>') html = '';
return html
}
function stateFromHtml(html = null) {
if(!RichTextEditor) return null;
if(html && html.trim() == '') html = null
return html ? RichTextEditor.createValueFromString(html, 'html')
: RichTextEditor.createEmptyValue()
return html ? deserializeHtml(html)
: getDemoState()
}
class ReplyEditor extends React.Component {
......@@ -118,7 +117,9 @@ class ReplyEditor extends React.Component {
const {onCancel, resetForm} = this.props
resetForm()
this.setAutoVote()
this.setState({rte_value: stateFromHtml()})
//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);
......@@ -163,6 +164,7 @@ class ReplyEditor extends React.Component {
if(rte) html = stripHtmlWrapper(html)
}
console.log("initial reply body:", html || '(empty)')
body.onChange(html)
this.setState({
rte,
......@@ -200,7 +202,7 @@ class ReplyEditor extends React.Component {
clearTimeout(saveEditorTimeout)
saveEditorTimeout = setTimeout(() => {
// console.log('save formId', formId, JSON.stringify(data, null, 0))
console.log('save formId', formId, JSON.stringify(data, null, 0))
localStorage.setItem('replyEditorData-' + formId, JSON.stringify(data, null, 0))
}, 350)
}
......@@ -213,7 +215,8 @@ 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.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);
......@@ -283,6 +286,7 @@ class ReplyEditor extends React.Component {
</div>
}
// TODO: remove all references to these vframe classes. Removed from css and no longer needed.
const vframe_class = isStory ? 'vframe' : '';
const vframe_section_class = isStory ? 'vframe__section' : '';
const vframe_section_shrink_class = isStory ? 'vframe__section--shrink' : '';
......@@ -312,11 +316,9 @@ class ReplyEditor extends React.Component {
</div>
}
{process.env.BROWSER && rte ?
<RichTextEditor ref="rte"
readOnly={loading}
value={this.state.rte_value}
onChange={this.onChange}
onBlur={body.onBlur} tabIndex={2} />
<SlateEditor ref="rte"
initialState={this.state.rte_value}
onChange={this.onChange} />
:
<textarea {...cleanReduxInput(body)} disabled={loading} rows={isStory ? 10 : 3} placeholder={isStory ? 'Write your story...' : 'Reply'} autoComplete="off" ref="postRef" tabIndex={2} />
}
......
.ReplyEditor {
max-width: 50rem;
max-width: 54rem;
margin-bottom: 0.5rem;
.rte {
clear: right;
}
}
.vframe {
display: block;
.public-DraftEditor-content {
height: 20em;
resize: vertical;
}
}
.PostFull .ReplyEditor__body {
margin: 1rem 0 0;
}
.ReplyEditor .Markdown {
.ReplyEditor form {
max-width: 52rem;
}
.ReplyEditor .Preview .Markdown {
border: 1px solid $light-gray;
padding: 0 1rem;
}
.ReplyEditor__body {
margin-top: 1rem;
border-radius: 4px;
> div {
border: none;
}
}
.ReplyEditor__body.rte {
border: 1px solid $medium-gray;
}
.ReplyEditor__options {
......@@ -45,33 +34,3 @@
text-transform: none;
}
}
.medium-editor-element {
background-color: inherit !important;
}
.medium-editor-toolbar-actions button {
margin-right: 0 !important;
}
// .medium-insert-images.medium-insert-images-grid.small-grid figure {
// width: 10%;
// }
// @media (max-width: 750px) {
// .medium-insert-images.medium-insert-images-grid.small-grid figure {
// width: 20%;
// }
// }
// @media (max-width: 450px) {
// .medium-insert-images.medium-insert-images-grid.small-grid figure {
// width: 25%;
// }
// }
// .editable {
// outline: none;
// min-height: 38px;
// margin: 0 0 20px 0;
// padding: 0 0 20px 0;
// }
.RichTextEditor__root___33zoV button {
margin-right: 0 !important;
}
import React from 'react'
import Slate, { Editor, Mark, Raw, Html } from 'slate'
import Portal from 'react-portal'
import position from 'selection-position'
import Icon from 'app/components/elements/Icon';
import demoState from 'app/utils/SlateEditor/DemoState'
import {HtmlRules, schema, getMarkdownType} from 'app/utils/SlateEditor/Schema'
const serializer = new Html({rules: HtmlRules})
export const serializeHtml = (state) => serializer.serialize(state)
export const deserializeHtml = (html) => serializer.deserialize(html)
export const getDemoState = () => Raw.deserialize(demoState, { terse: true })
const DEFAULT_NODE = 'paragraph'
let plugins = []
import InsertBlockOnEnter from 'slate-insert-block-on-enter'
import TrailingBlock from 'slate-trailing-block'
if(process.env.BROWSER) {
//import InsertImages from 'slate-drop-or-paste-images'
const InsertImages = require('slate-drop-or-paste-images').default
plugins.push(
InsertImages({
extensions: ['jpeg', 'png', 'gif'],
applyTransform: (transform, file) => {
return transform.insertBlock({
type: 'image',
isVoid: true,
data: { file }
})
}
})
)
plugins.push(
InsertBlockOnEnter({kind: 'block', type: DEFAULT_NODE, nodes: [{kind: 'text', text: '', ranges: []}]})
)
plugins.push(
TrailingBlock({ type: DEFAULT_NODE })
)
}
export default class SlateEditor extends React.Component {
constructor(props) {
super(props)
this.state = {state: props.initialState}
}
reset = () => {
this.setState({state: this.props.initialState})
}
componentDidMount = () => {
this.updateMenu()
this.updateSidebar()
}
componentDidUpdate = () => {
this.updateMenu()
this.updateSidebar()
}
onChange = (state) => {
//this.setState({ state })
this.props.onChange(state)
}
// When the portal opens, cache the menu element.
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) => {
const { state } = this.state
if (!state.isExpanded) return
return state.marks.some(mark => mark.type == type)
}
// Check if the current selection has a block with `type` in it.
hasBlock = (type) => {
const { state } = this.state
const { document } = state
return state.blocks.some(node => (node.type == type) || !!document.getClosest(node, parent => parent.type == type) )
}
// Check if the current selection has an inline of `type`.
hasInline = (type) => {
const { state } = this.state
return state.inlines.some(inline => inline.type == type)
}
// 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 })
}
// Toggle block type
onClickBlock = (e, type) => {
e.preventDefault()
let { state } = this.state
let transform = state.transform()
const { document } = state
// Handle everything but list buttons.
if (type != 'bulleted-list' && type != 'numbered-list') {
const isActive = this.hasBlock(type)
const isList = this.hasBlock('list-item')
if (isList) {
transform = transform
.setBlock(isActive ? DEFAULT_NODE : type)
.unwrapBlock('bulleted-list')
.unwrapBlock('numbered-list')
}
else {
transform = transform
.setBlock(isActive ? DEFAULT_NODE : type)
}
}
// Handle the extra wrapping required for list buttons.
else {
const isList = this.hasBlock('list-item')
const isType = state.blocks.some((block) => {
return !!document.getClosest(block, parent => parent.type == type)
})
if (isList && isType) {
transform = transform
.setBlock(DEFAULT_NODE)
.unwrapBlock('bulleted-list')
.unwrapBlock('numbered-list')
} else if (isList) {
transform = transform
.unwrapBlock(type == 'bulleted-list' ? 'numbered-list' : 'bulleted-list')
.wrapBlock(type)
} else {
transform = transform
.setBlock('list-item')
.wrapBlock(type)
}
}
state = transform.apply()
this.setState({ state })
}
onClickLink = (e) => {
e.preventDefault()
let { state } = this.state
const hasLinks = this.hasInline('link')
if (hasLinks) {
console.log(JSON.stringify(Raw.serialize(state, {terse: false}), null, 2))
state = state
.transform()
.unwrapInline('link')
.apply()
}
else if (state.isExpanded) {
const href = window.prompt('Enter the URL of the link:', 'http://steemit.com')
if(href) {
state = state
.transform()
.wrapInline({
type: 'link',
data: { href }
})
.collapseToEnd()
.apply()
}
}
else {
const href = window.prompt('Enter the URL of the link:')
const text = window.prompt('Enter the text for the link:')
state = state
.transform()
.insertText(text)
.extendBackward(text.length)
.wrapInline({
type: 'link',
data: { href }
})
.collapseToEnd()
.apply()
}
console.log(JSON.stringify(Raw.serialize(state, {terse: false}), null, 2))
this.setState({ state })
}
// Markdown-style quick formatting
onKeyDown = (e, data, state) => {
if(data.isMod) return this.onModKeyDown(e, data, state);
switch (data.key) {
case 'space': return this.onSpace(e, state)
case 'backspace': return this.onBackspace(e, state)
case 'enter': return data.isShift ? this.onShiftEnter(e, state) : this.onEnter(e, state)
}
}
onModKeyDown = (e, data, state) => {
let mark
switch (data.key) {
case 'b':
mark = 'bold'
break
case 'i':
mark = 'italic'
break
case 'u':
mark = 'underline'
break
case 'k':
return this.onClickLink(e);
}
if(!mark) return;
state = state
.transform()
.toggleMark(mark)
.apply()
e.preventDefault()
return 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 = getMarkdownType(chars)
if (!type) return
if (type == 'list-item' && startBlock.type == 'list-item') return
e.preventDefault()
let transform = state
.transform()
.setBlock(type)
if (type == 'list-item' && chars != '1.') transform = transform.wrapBlock('bulleted-list')
if (type == 'list-item' && chars == '1.') 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 == 'list-item')
transform = transform
.unwrapBlock('bulleted-list')
.unwrapBlock('numbered-list')
state = transform.apply()
return state
}
onShiftEnter = (e, state) => {
if (state.isExpanded) return
const { startBlock, startOffset, endOffset } = state
// Allow soft returns for certain block types
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')
return transform.apply()
}
}
onEnter = (e, state) => {
if (state.isExpanded) return
const { startBlock, startOffset, endOffset } = state
// On return, if at the end of a node type that should not be extended, create a new paragraph below it.
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' &&
startBlock.type != 'code-block'
) return
e.preventDefault()
return state
.transform()
.splitBlock()
.setBlock('paragraph')
.apply()
}
onPaste = (e, data, state) => {
console.log("** onPaste:", data.type, data.html)
if (data.type != 'html') return
const { document } = serializer.deserialize(data.html)
return state
.transform()
.insertFragment(document)
.apply()
}
render = () => {
const { state } = this.state
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: 'image', isVoid: true, data: {src}})
.insertBlock({type: 'paragraph', isVoid: false, nodes: [Slate.Text.create()]})
.focus()
.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.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>&ldquo;</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: 'code-block', label: '<>'})}
</div>
</Portal>
)
}
renderMarkButton = (props) => {
const {type, label} = props
const isActive = this.hasMark(type)
const onMouseDown = e => this.onClickMark(e, type)
return (
<span key={type} className={'SlateEditor__menu-button SlateEditor__menu-button-'+type} onMouseDown={onMouseDown} data-active={isActive}>
<span>{label}</span>
</span>
)
}
renderBlockButton = (props) => {
const {type, label} = props
const isActive = this.hasBlock(type)
const onMouseDown = e => this.onClickBlock(e, type)
return (
<span key={type} className={'SlateEditor__menu-button SlateEditor__menu-button-'+type} onMouseDown={onMouseDown} data-active={isActive}>
<span>{label}</span>
</span>
)
}
renderInlineButton = (props) => {
const {type, label} = props
const isActive = this.hasInline(type)
const onMouseDown = e => this.onClickLink(e, type)
return (
<span key={type} className={'SlateEditor__menu-button SlateEditor__menu-button-'+type} onMouseDown={onMouseDown} data-active={isActive}>
<span>{label}</span>
</span>
)
}
renderEditor = () => {
return (
<div className="SlateEditor Markdown">
<Editor
schema={schema}
plugins={plugins}
state={this.state.state}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
onPaste={this.onPaste}
/>
</div>
)
}
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
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`
}
}
.SlateEditor.Markdown {
a {
border-bottom: 1px dotted #00f;
position: relative;
img {border: 1px dotted #00f}
}
img.active {box-shadow: 0 0 2px 1px #48C;}
hr.active {box-shadow: 0 0 2px 1px #48C;}
a:hover:after{
font-family: Arial;
border-radius: 3px;
padding: 1px 3px;
background: #eee;
content: attr(href);
display: block;
position: absolute;
left: -25%;
top: 110%;
line-height: 1;
white-space: nowrap;
font-size: 60%;
z-index: 99999;
padding: 4px 8px;
color: #ddd;
transition: opacity .75s;
background-image: linear-gradient(180deg,#464646,#151515);
}
}
.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__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 {
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 {
font-family: 'Georgia', serif;
color: #FFF;
cursor: pointer;
display: inline-block;
width: 2rem;
height: 1.75rem;
text-align: center;
background: rgba(0,0,0,0.1);
border-radius: 2px;
> span {
path {fill: white}
}
}
.SlateEditor__menu-button[data-active="false"]:hover,
.SlateEditor__menu-button[data-active="true"] {
color: #32cd32;
background: rgba(0,0,0,0.5);
> span {
path {fill: #32cd32;}
}
}
.SlateEditor__menu-button-code > span > code {
border: none;
background: transparent;
color: inherit;
padding: 0;
font-size: 90%;
vertical-align: top;
}
.SlateEditor__menu-button-sup > span > span ,
.SlateEditor__menu-button-sub > span > span {
font-family: Arial;
font-size: 80%;
vertical-align: 4%;
}
.SlateEditor__menu-button-link {
.Icon,
.Icon > svg {width: 1rem; height: 1rem;}
}
.SlateEditor__menu-button-block-quote > span > span {
font-size: 220%;
vertical-align: -45%;
line-height: 1;
}
......@@ -20,6 +20,7 @@ export const userWatches = [
loginErrorWatch,
lookupPreviousOwnerAuthorityWatch,
watchLoadSavingsWithdraw,
uploadImageWatch,
]
const highSecurityPages = Array(/\/market/, /\/@.+\/(transfers|permissions|password)/, /\/~witnesses/)
......@@ -348,6 +349,91 @@ function* lookupPreviousOwnerAuthority({payload: {}}) {
yield put(user.actions.setUser({previous_owner_authority}))
}
import {Signature, hash} from 'shared/ecc'
function* uploadImageWatch() {
yield* takeLatest('user/UPLOAD_IMAGE', uploadImage);
}
function* uploadImage({payload: {file, dataUrl, filename = 'image.txt', progress}}) {
const _progress = progress
progress = msg => {
console.log('Upload image progress', msg)
_progress(msg)
}
const stateUser = yield select(state => state.user)
const username = stateUser.getIn(['current', 'username'])
const d = stateUser.getIn(['current', 'private_keys', 'posting_private'])
if(!username) {
progress({error: 'Not logged in'})
return
}
if(!d) {
progress({error: 'Login with your posting key'})
return
}
if(!file && !dataUrl) {
console.error('uploadImage required: file or dataUrl')
return
}
let data, dataBs64
if(file) {
// drag and drop
const reader = new FileReader()
data = yield new Promise(resolve => {
reader.addEventListener('load', () => {
const result = new Buffer(reader.result, 'binary')
resolve(result)
})
reader.readAsBinaryString(file)
})
} else {
// recover from preview
const commaIdx = dataUrl.indexOf(',')
dataBs64 = dataUrl.substring(commaIdx + 1)
data = new Buffer(dataBs64, 'base64')
}
const bufSha = hash.sha256(data)
const formData = new FormData()
if(file) {
formData.append('file', file)
} else {
// formData.append('file', file, filename) <- Failed to add filename=xxx to Content-Disposition
// Can't easily make this look like a file so this relies on the server supporting: filename and filebinary
formData.append('filename', filename)
formData.append('filebase64', dataBs64)
}
const sig = Signature.signBufferSha256(bufSha, d)
const postUrl = `${$STM_Config.uploadImage}/${username}/${sig.toHex()}`
fetch(postUrl, {
method: 'post',
body: formData
})
.then(r => r.json())
.then(res => {
const {error} = res
if(error) {
progress({error: 'Error: ' + error})
return
}
const {url} = res
progress({url})
})
.catch(error => {
console.error(filename, error)
progress({error: 'Unable to contact the server.'})
return
})
}
// function* getCurrentAccount() {
// const current = yield select(state => state.user.get('current'))
// if (!current) return
......
......@@ -86,7 +86,7 @@ export default ({large = true, highQualityPost = true, noImage = false, sanitize
let {src, alt} = attribs
if(!/^(https?:)?\/\//i.test(src)) {
console.log('Blocked, image tag src does not appear to be a url', tagName, attribs)
sanitizeErrors.push('Image URL does not appear to be valid: ' + src)
sanitizeErrors.push('An image in this post did not save properly.')
return {tagName: 'img', attribs: {src: 'brokenimg.jpg'}}
}
......
export default {
"nodes": [
{
"kind": "block",
"type": "paragraph",
"nodes": [
{
"kind": "text",
"ranges": [
{
"text": "This is editable "
},
{
"text": "rich",
"marks": [
{
"type": "bold"
}
]
},
{
"text": " text, "
},
{
"text": "much",
// "marks": [
// {
// "type": "italic"
// }
// ]
},
{
"text": " better than a "
},
{
"text": "<textarea>",
"marks": [
{
"type": "code"
}
]
},
{
"text": "!"
}
]
}
]
},
{
"kind": "block",
"type": "paragraph",
"nodes": [
{
"kind": "text",
"ranges": [
{
"text": "Since it's rich text, you can do things like turn a selection of text "
},
{
"text": "bold",
"marks": [
{
"type": "bold"
}
]
},{
"text": ", or add a semantically rendered block quote in the middle of the page, like this:"
}
]
}
]
},
{
"kind": "block",
"type": "block-quote",
"nodes": [
{
"kind": "text",
"text": "A wise quote."
}
]
},
{
"kind": "block",
"type": "paragraph",
"nodes": [
{
"kind": "text",
"text": "Try it out for yourself!"
}
]
}
]
}
import React from 'react'
export default class HRule extends React.Component {
render() {
const { node, state } = this.props
const isFocused = state.selection.hasEdgeIn(node)
const className = isFocused ? 'active' : null
return <hr className={className} />
}
}
import React from 'react'
import {connect} from 'react-redux'
export default connect(
(state, ownProps) => ownProps,
dispatch => ({
uploadImage: (file, dataUrl, filename, progress) => {
dispatch({
type: 'user/UPLOAD_IMAGE',
payload: {file, dataUrl, filename, progress},
})
},
})
)(
class Image extends React.Component {
state = {
progress: {},
};
componentWillMount() {
const file = this.props.node.data.get('file')
// Save `file` for "Retry"
// Try to load incase data url was loaded from a draft
this.setState({ file })
}
componentDidMount() {
console.log("** image mounted..", this.state, this.props)
this.load()
}
setImageSrc(src, filename) {
const {editor, node} = this.props
const state = editor.getState();
const next = state
.transform()
.setNodeByKey(node.key, { data: { src, alt: filename } })
.apply()
editor.onChange(next)
}
load = () => {
let dataUrl, filename
const {file} = this.state
if(file) {
// image dropped -- show a quick preview
console.log("** image being loaded.. ----->", file)
const reader = new FileReader()
reader.addEventListener('load', () => {
dataUrl = reader.result
this.setImageSrc(dataUrl, file.name)
})
reader.readAsDataURL(file)
filename = file.name
} else {
// draft, recover data using the preview data url
const {data} = this.props.node
dataUrl = data.get('src')
filename = data.get('alt')
}
if(!file && !dataUrl) return
this.setState({ progress: {}, uploading: true}, () => {
const {uploadImage} = this.props
uploadImage(file, dataUrl, filename, progress => {
this.setState({ progress, uploading: false })
if(progress.url) {
this.setImageSrc(progress.url, filename)
}
})
})
}
render() {
const { node, state, attributes } = this.props
const isFocused = state.selection.hasEdgeIn(node)
const className = isFocused ? 'active' : null
const src = node.data.get('src')
if(src) {
console.log("** uploaded image being rendered..", (src ? src.substring(0, 30) : '') + '...', state)
}
// if(!src) {
// src = 'https://img1.steemit.com/0x0/http://ariasprado.name/wp-content/uploads/2012/09/missing-tile-256x256.png'
// src = $STM_Config.img_proxy_prefix + '0x0/' + src
// }
if(!src) return <small className="info">Loading Image&hellip;</small>
const alt = node.data.get('alt')
const img = <img {...attributes} src={src} alt={alt} className={className} />
if(/^https?:\/\//.test(src)) return img
const {uploading} = this.state
if(uploading)
return <div>
{img}
<br />
<small className="info">Uploading Image&hellip;</small>
</div>
const { error } = this.state.progress
return <div>
{img}
<div className="error">
<small>
Image was not Saved (<a onClick={this.load}>retry</a>)
<br />
{error}
</small>
</div>
</div>
}
})
\ No newline at end of file
import React from 'react'
export default class Link extends React.Component {
state = {};
componentDidMount() {
}
render() {
const { node, state, attributes, children } = this.props
const isFocused = state.selection.hasEdgeIn(node)
const className = isFocused ? 'active' : null
const href = node.data.get('href')
return <a {...attributes} href={href} className={className}>{children}</a>
}
}
import React from 'react'
import Link from 'app/utils/SlateEditor/Link'
import Image from 'app/utils/SlateEditor/Image'
import HRule from 'app/utils/SlateEditor/HRule'
const $ = require('cheerio');
/*
--unsupported
div ['pull-right', 'pull-left', 'text-justify', 'text-rtl'], center
iframe
table, thead, tbody, tr, th, td
*/
// Map html --> block type
const BLOCK_TAGS = {
p: 'paragraph',
blockquote: 'block-quote',
pre: 'code-block',
h1: 'heading-one',
h2: 'heading-two',
h3: 'heading-three',
h4: 'heading-four',
ul: 'bulleted-list',
ol: 'numbered-list',
li: 'list-item',
hr: 'hr',
}
// Map HTML --> mark type
const MARK_TAGS = {
em: 'italic',
i: 'italic',
strong: 'bold',
b: 'bold',
u: 'underline',
del: 'strike',
strike: 'strike',
sup: 'sup',
sub: 'sub',
}
export const HtmlRules = [
// Block rules
{
deserialize: (el, next) => {
const type = BLOCK_TAGS[el.tagName]
if (!type) return
// Special case for <pre>: ignore its inner <code> element.
const code = el.tagName == 'pre' ? el.children[0] : null
const children = code && code.tagName == 'code' ? code.children : el.children
return {
kind: 'block',
type: type,
isVoid: (type == 'hr'),
nodes: next(children)
}
},
serialize: (object, children) => {
if(object.kind !== 'block') return
switch(object.type) {
case 'paragraph': return <p>{children}</p>
case 'block-quote': return <blockquote>{children}</blockquote>
case 'code-block': return <pre><code>{children}</code></pre>
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': return <ul>{children}</ul>
case 'numbered-list': return <ol>{children}</ol>
case 'list-item': return <li>{children}</li>
case 'hr': return <hr />
}
}
},
// Mark rules
{
deserialize: (el, next) => {
const type = MARK_TAGS[el.tagName]
if (!type) return
return {
kind: 'mark',
type: type,
nodes: next(el.children)
}
},
serialize: (object, children) => {
if(object.kind !== 'mark') return;
switch(object.type) {
case 'bold': return <strong>{children}</strong>
case 'italic': return <i>{children}</i>
case 'underline': return <u>{children}</u>
case 'strike': return <del>{children}</del>
case 'code': return <code>{children}</code>
case 'sup': return <sup>{children}</sup>
case 'sub': return <sub>{children}</sub>
}
}
},
// Custom
{
deserialize: (el, next) => {
switch(el.tagName) {
case 'img':
return {
kind: 'block',
type: 'image',
isVoid: true,
data: {src: el.attribs.src, alt: el.attribs.al},
nodes: next(el.children)
}
case 'a':
const {href} = el.attribs
if(!href) console.log("** ERR: deserialized <a> with no href")
return {
kind: 'inline',
type: 'link',
data: {href: href},
nodes: next(el.children)
}
case 'br':
return {
"kind": "text",
"ranges": [{"text": "\n"}]
}
case 'code':
if(! $(el).closest('pre')) {
return {
kind: 'mark',
type: 'code',
nodes: next(el.children)
}
} else {
console.log("** skipping <code> within a <pre>")
}
}
if(el.type == 'text') return
if(BLOCK_TAGS[el.tagName] || MARK_TAGS[el.tagName]) return
console.log("No deserializer for: ", el.tagName, el)
},
serialize: (object, children) => {
if(object.kind == 'string') return;
if(object.kind == 'inline' && object.type == 'link') {
const href = object.data.get('href')
if(!href) console.log("** ERR: serializing <a> with no href", JSON.stringify(object.data, null, 2))
return <a href={href}>{children}</a>
}
if(object.kind == 'block' && object.type == 'image') {
const src = object.data.get('src')
const alt = object.data.get('alt')
if(!src) console.log("** ERR: serializing image with no src...", JSON.stringify(object))
return <img src={src} alt={alt} />
}
console.log("No serializer for: ", object.kind, JSON.stringify(object, null, 2), children)
}
}
]
export const schema = {
defaultNode: 'paragraph',
toolbarMarks: [
{ type: 'bold', label: <strong>B</strong> },
{ type: 'italic', label: <i>i</i> },
//{ type: 'underline', label: <u>U</u> },
//{ type: 'strike', label: <del>S</del> },
{ type: 'code', label: <code>{'{}'}</code> },
{ type: 'sup', label: <span>x<sup>2</sup></span> },
{ type: 'sub', label: <span>x<sub>2</sub></span> },
],
/*
blockTypes: {
...Blocks,
},
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: {
'paragraph': ({ children, attributes }) => <p {...attributes}>{children}</p>,
'code-block': ({ children, attributes }) => <pre {...attributes}><code>{children}</code></pre>,
'block-quote': ({ children, attributes }) => <blockquote {...attributes}>{children}</blockquote>,
'bulleted-list': ({ children, attributes }) => <ul {...attributes}>{children}</ul>,
'numbered-list': ({ children, attributes }) => <ol {...attributes}>{children}</ol>,
'heading-one': ({ children, attributes }) => <h1 {...attributes}>{children}</h1>,
'heading-two': ({ children, attributes }) => <h2 {...attributes}>{children}</h2>,
'heading-three': ({ children, attributes }) => <h3 {...attributes}>{children}</h3>,
'heading-four': ({ children, attributes }) => <h4 {...attributes}>{children}</h4>,
'list-item': ({ children, attributes }) => <li {...attributes}>{children}</li>,
'hr': HRule,
'image': Image,
'link': Link,
},
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 => <del>{props.children}</del>,
sub: props => <sub>{props.children}</sub>,
sup: props => <sup>{props.children}</sup>,
},
}
export const getMarkdownType = (chars) => {
switch (chars) {
case '1.':
case '*':
case '-': return 'list-item';
case '>': return 'block-quote';
case '#': return 'heading-one';
case '##': return 'heading-two';
case '###': return 'heading-three';
case '####': return 'heading-four';
case ' ': return 'code-block';
case '---': return 'hr';
default: return null;
}
}
......@@ -20,7 +20,8 @@ global.$STM_Config = {
ipfs_prefix: config.ipfs_prefix,
disable_signups: config.disable_signups,
read_only_mode: config.read_only_mode,
registrar_fee: config.registrar.fee
registrar_fee: config.registrar.fee,
uploadImage: config.uploadImage,
};
const WebpackIsomorphicTools = require('webpack-isomorphic-tools');
......