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 (27)
Showing
with 1274 additions and 99 deletions
......@@ -59,6 +59,13 @@ find node_modules/eslint-config-airbnb -name '*.js'|xargs sed -i "s/': 2/': 1/"
"react/sort-comp": [1, { "order": [ 'lifecycle' ] }],
"react/prefer-stateless-function": 0,
"react/prop-types": 0,
"radix": 0,
"radix": 1,
"no-multi-spaces": 0,
"array-bracket-spacing": 1,
"no-trailing-spaces": 1,
"no-unused-vars": 1,
"padded-blocks": 1,
"quote-props": 1,
"key-spacing": 0,
}
}
<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
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M19,19H5V5H19M19,3H5A2,2 0 0,0 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3M10,8V16L15,12L10,8Z" /></svg>
\ No newline at end of file
......@@ -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,9 @@ const icons = [
'flag1',
'flag2',
'reblog',
'photo',
'line',
'video',
];
const icons_map = {};
for (const i of icons) icons_map[i] = require(`app/assets/icons/${i}.svg`);
......
import React from 'react'
import { Editor, Plain, Raw } from 'slate'
import Portal from 'react-portal'
import position from 'selection-position'
import Icon from 'app/components/elements/Icon';
import {schema} from 'app/utils/SlateEditor/SchemaMarkdown'
const serializer = Plain
export const serializeMarkdown = (state) => serializer.serialize(state)
export const deserializeMarkdown = (md) => serializer.deserialize(md)
const DEFAULT_NODE = 'paragraph'
let plugins = []
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', 'jpg', 'png', 'gif'],
applyTransform: (transform, file) => {
return transform.insertInline({
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 MarkdownEditor extends React.Component {
constructor(props) {
super(props)
this.state = {state: props.initialState}
}
reset = () => {
this.setState({state: this.props.initialState})
}
focus = () => {
this.refs.editor.focus()
}
onChange = (state) => {
//this.setState({ state })
this.props.onChange(state)
}
// Markdown-style quick formatting
onKeyDown = (e, data, state) => {
if(data.isMod) return this.onModKeyDown(e, data, state);
switch (data.key) {
// case 'backspace': return this.onBackspace(e, state)
case 'enter': return data.isShift ? this.onShiftEnter(e, state) : this.onEnter(e, 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
// }
onEnter = (e, state) => {
e.preventDefault()
return state
.transform()
.splitBlock()
.setBlock('div')
.apply()
}
onPaste = (e, data, state) => {
console.log("** onPaste:", data.type, data.html)
if (data.type != 'html') return null
const { document } = serializer.deserialize(data.html)
return state
.transform()
.insertFragment(document)
.apply()
}
renderEditor = ({placeholder}) => {
return (
<div className="SlateEditor Markdown">
<Editor
ref="editor"
schema={schema}
plugins={plugins}
placeholder={placeholder}
state={this.state.state}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
onPaste={this.onPaste}
focus={this.focus}
/>
</div>
)
}
render = () => {
const {placeholder} = this.props
return (
<div>
{this.renderEditor({placeholder})}
</div>
)
}
}
export const getInitalMarkdownState = () =>
Raw.deserialize({
nodes: [
{ kind: 'block', type: 'paragraph', nodes:
[{ kind: 'text', ranges: [{text: ''}]
}
]
}]}, { terse: true })
......@@ -15,16 +15,18 @@ 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'
import MarkdownEditor, {serializeMarkdown, deserializeMarkdown, getInitalMarkdownState}
from 'app/components/elements/MarkdownEditor'
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 +47,27 @@ 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()
}
function stateToMarkdown(state) {
const md = serializeMarkdown(state)
return md
}
function stateFromMarkdown(md = null) {
if(md && md.trim() == '') md = null
return md ? deserializeMarkdown(md)
: getInitalMarkdownState()
}
class ReplyEditor extends React.Component {
......@@ -102,7 +114,7 @@ class ReplyEditor extends React.Component {
type: 'submit_comment',
}
constructor() {
constructor(props) {
super()
this.state = {}
this.shouldComponentUpdate = shouldComponentUpdate(this, 'ReplyEditor')
......@@ -118,11 +130,19 @@ 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.onCancelMarkdown = e => {
if(e) e.preventDefault()
const {onCancel, resetForm} = this.props
resetForm()
this.setAutoVote()
this.refs.markdown.setState({state: stateFromMarkdown('')})
if(onCancel) onCancel(e)
}
this.onChange = this.onChange.bind(this);
this.toggleRte = this.toggleRte.bind(this);
this.autoVoteOnChange = () => {
const {autoVote} = this.props.fields
const key = 'replyEditorData-autoVote-story'
......@@ -136,7 +156,6 @@ class ReplyEditor extends React.Component {
setMetaData(formId, jsonMetadata)
if(process.env.BROWSER) {
// Check for rte editor preference
let rte = this.props.isStory && JSON.parse(localStorage.getItem('replyEditorData-rte') || RTE_DEFAULT);
let html = null;
......@@ -149,6 +168,7 @@ class ReplyEditor extends React.Component {
// Check for draft data
let draft = localStorage.getItem('replyEditorData-' + formId)
console.log('replyEditorData- + formId', 'replyEditorData-' + formId, draft)
if(draft) {
draft = JSON.parse(draft)
const {category, title} = this.props.fields
......@@ -163,11 +183,14 @@ class ReplyEditor extends React.Component {
if(rte) html = stripHtmlWrapper(html)
}
console.log("initial reply body:", html || '(empty)')
body.onChange(html)
this.setState({
rte,
rte_value: rte ? stateFromHtml(html) : null
rte_value: rte ? stateFromHtml(html) : null,
md_value: stateFromMarkdown(rte ? '' : html),
})
console.log('stateFromMarkdown(rte ? : html)', stateFromMarkdown(rte ? '' : html))
this.setAutoVote()
this.setState({payoutType: this.props.isStory ? (localStorage.getItem('defaultPayoutType') || '50%') : '50%'})
}
......@@ -176,8 +199,8 @@ class ReplyEditor extends React.Component {
componentDidMount() {
setTimeout(() => {
if (this.props.isStory) this.refs.titleRef.focus()
else if (this.refs.postRef) this.refs.postRef.focus()
else if (this.refs.rte) this.refs.rte._focus()
else if (this.refs.markdown) this.refs.markdown.focus()
else if (this.refs.rte) this.refs.rte.focus()
}, 300)
}
componentWillReceiveProps(nextProps) {
......@@ -200,7 +223,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)
}
......@@ -212,13 +235,22 @@ class ReplyEditor extends React.Component {
}
// As rte_editor is updated, keep the (invisible) 'body' field in sync.
onChange(rte_value) {
this.setState({rte_value})
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);
}
// As markdown editor is updated, keep the (invisible) 'body' field in sync.
onChangeMarkdown = state => {
this.refs.markdown.setState({state})
const md = stateToMarkdown(state)
const body = this.props.fields.body
if(body.value !== md) body.onChange(md);
}
setAutoVote() {
const {isStory} = this.props
if(isStory) {
......@@ -228,11 +260,14 @@ class ReplyEditor extends React.Component {
autoVote.onChange(autoVoteDefault)
}
}
toggleRte(e) {
toggleRte = e => {
e.preventDefault();
const state = {rte: !this.state.rte};
if (state.rte) {
state.rte_value = stateFromHtml(this.props.fields.body.value);
} else {
state.md_value = stateFromMarkdown(this.props.fields.body.value);
}
this.setState(state);
localStorage.setItem('replyEditorData-rte', !this.state.rte)
......@@ -251,7 +286,8 @@ class ReplyEditor extends React.Component {
category: this.props.category,
body: this.props.body,
}
const {onCancel, autoVoteOnChange} = this
const {autoVoteOnChange} = this
const onCancel = this.state.rte ? this.onCancel : this.onCancelMarkdown
const {title, category, body, autoVote} = this.props.fields
const {
reply, username, hasCategory, isStory, formId, noImage,
......@@ -283,6 +319,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,13 +349,16 @@ 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} />
<MarkdownEditor ref="markdown"
initialState={this.state.md_value}
onChange={this.onChangeMarkdown}
disabled={loading} rows={isStory ? 10 : 3}
placeholder={isStory ? 'Write your story...' : 'Reply'}
/>
}
</div>
<div className={vframe_section_shrink_class}>
......@@ -338,7 +378,7 @@ class ReplyEditor extends React.Component {
{!loading && <button type="submit" className="button" disabled={submitting || invalid} tabIndex={4}>{isEdit ? 'Update Post' : postLabel}</button>}
{loading && <span><br /><LoadingIndicator type="circle" /></span>}
&nbsp; {!loading && this.props.onCancel &&
<button type="button" className="secondary hollow button no-border" tabIndex={5} onClick={(e) => {e.preventDefault(); onCancel()}}>Cancel</button>
<button type="button" className="secondary hollow button no-border" tabIndex={5} onClick={onCancel}>Cancel</button>
}
{!loading && !this.props.onCancel && <button className="button hollow no-border" tabIndex={5} disabled={submitting} onClick={onCancel}>Clear</button>}
......
.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', 'jpg', 'png', 'gif'],
applyTransform: (transform, file) => {
return transform.insertInline({
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}
}
componentDidMount = () => {
this.updateMenu()
this.updateSidebar()
}
componentDidUpdate = () => {
this.updateMenu()
this.updateSidebar()
}
reset = () => {
this.setState({state: this.props.initialState})
}
focus = () => {
this.refs.editor.focus()
}
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: 'video', label: <Icon name="video" />, handler: this.onClickInsertVideo})}
{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()
.insertInline({type: 'image', isVoid: true, data: {src}})
//.insertBlock({type: 'paragraph', isVoid: false, nodes: [Slate.Text.create()]})
.focus()
.collapseToEndOfNextBlock()
.apply()
this.setState({ state })
}
onClickInsertVideo = (e, type) => {
e.preventDefault()
let { state } = this.state
state = state
.transform()
.insertBlock({type: 'embed', isVoid: true, data: {src: 'https://www.youtube.com/watch?v=7YOozVnEdFQ'}})
.insertBlock({type: 'paragraph', isVoid: false})
.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
ref="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}
}
div.active,
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
......
......@@ -30,6 +30,7 @@ export default {
imageFile: imageFile(),
youTube: youTube(),
youTubeId: /(?:(?:youtube.com\/watch\?v=)|(?:youtu.be\/)|(?:youtube.com\/embed\/))([A-Za-z0-9\_\-]+)/i,
vimeoId: /(?:vimeo.com\/|player.vimeo.com\/video\/)([0-9]+)/,
// simpleLink: new RegExp(`<a href="(.*)">(.*)<\/a>`, 'ig'),
ipfsPrefix: /(https?:\/\/.*)?\/ipfs/i,
}
......
......@@ -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 linksRe from 'app/utils/Links'
export default class Iframe extends React.Component {
normalizeEmbedUrl = (url) => {
let match;
// Detect youtube URLs
match = url.match(linksRe.youTubeId)
if(match && match.length >= 2) {
return 'https://www.youtube.com/embed/' + match[1]
}
// Detect vimeo
match = url.match(linksRe.vimeoId)
if(match && match.length >= 2) {
return 'https://player.vimeo.com/video/' + match[1]
}
console.log("unable to auto-detect embed url", url)
return null
}
onChange = (e) => {
const { node, state, editor } = this.props
const value = e.target.value
const src = this.normalizeEmbedUrl(value) || value
const next = editor
.getState()
.transform()
.setNodeByKey(node.key, {data: {src}})
.apply()
editor.onChange(next)
}
onClick = (e) => {
// stop propagation so that the void node itself isn't focused, since that would unfocus the input.
e.stopPropagation()
}
render = () => {
const { node, state, attributes } = this.props
const isFocused = state.selection.hasEdgeIn(node)
const className = isFocused ? 'active' : null
const style = {background: 'black', color: 'white'}
return (
<div {...attributes} className={className} style={style}>
{this.renderFrame()}
Embed URL: {this.renderInput()}
</div>
)
}
renderFrame = () => {
let src = this.props.node.data.get('src')
src = this.normalizeEmbedUrl(src) || src
const aspectStyle = {
position: 'relative',
paddingBottom: '56.2%',
height: '0'
}
const lockStyle = {
position: 'absolute',
top: '0px',
left: '0px',
width: '100%',
height: '100%',
background: 'rgba(0,0,0,0.1)'
}
const style = {
position: 'absolute',
top: '0px',
left: '0px',
width: '100%',
height: '100%'
}
return (
<div style={aspectStyle}>
<iframe
type="text/html"
width="640"
height="360"
src={src}
frameBorder="0"
style={style}>
</iframe>
<div style={lockStyle}></div>
</div>
)
}
renderInput = () => {
const src = this.props.node.data.get('src')
return (
<input
value={src}
onChange={this.onChange}
onClick={this.onClick}
style={{ marginTop: '5px', background: 'black' }}
size="70"
/>
)
}
}