add semantic css local, begin implement markdown diff, etc.

This commit is contained in:
Adam Brown 2016-12-09 17:07:42 -05:00
parent 27582ef871
commit 55a30797ec
16 changed files with 8530 additions and 8133 deletions

31
TODO.md
View File

@ -1,2 +1,33 @@
- create production mode build and serve settings: `webpack.js` and `src/server/babel.index.js` - create production mode build and serve settings: `webpack.js` and `src/server/babel.index.js`
## State changes
main page edits input documents
compare page views compare documents
compare button:
- generate id
- post input documents to server
- input documents copied to compare documents
- input documents cleared
- go to compare route
edit button:
- compare documents copied to input documents
- compare documents cleared
- go to main route
client start:
- load input documents from localStore
server start:
- load compare documents from database
* client actually never needs to query server for compare documents... huh!

16278
dist/browser-bundle.js vendored

File diff suppressed because one or more lines are too long

11
dist/semantic.min.css vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -4,8 +4,9 @@
"description": "", "description": "",
"main": "src/server/babel.index.js", "main": "src/server/babel.index.js",
"scripts": { "scripts": {
"build": "ebpack --colors", "copy-css": "copyfiles -f ./node_modules/semantic-ui-css/semantic.min.css ./dist",
"start": "webpack --progress --colors --watch", "build": "npm run copy-css && webpack --colors",
"start": "npm run copy-css && webpack --progress --colors --watch",
"serve": "node src/server/babel.index.js", "serve": "node src/server/babel.index.js",
"webpack-stats": "webpack --profile --json > stats.json", "webpack-stats": "webpack --profile --json > stats.json",
"test": "echo \"Error: no test specified\" && exit 1" "test": "echo \"Error: no test specified\" && exit 1"
@ -27,6 +28,7 @@
"redux": "^3.5.1", "redux": "^3.5.1",
"redux-router": "^1.0.0-beta5", "redux-router": "^1.0.0-beta5",
"reselect": "^2.5.1", "reselect": "^2.5.1",
"semantic-ui-css": "^2.2.4",
"semantic-ui-react": "^0.61.6", "semantic-ui-react": "^0.61.6",
"uuid": "^3.0.1" "uuid": "^3.0.1"
}, },

View File

@ -13,14 +13,27 @@ import * as reducers from '../common/reducers'
import routes from '../common/routes' import routes from '../common/routes'
//the localStore implementation is naive
//initial state should be rehydrated from the server
//then additional state transformations should be applied based on localStore contents
// (or not? maybe localStore is not needed)
const initialState = window.__INITIAL_STATE__
//create the redux store //create the redux store
//initial state is retrieved from localStore //initial state is retrieved from localStore
const store = Redux.createStore( const store = Redux.createStore(
Redux.combineReducers(reducers), Redux.combineReducers(reducers),
localStore.get("dubdiff"), initialState,
window.devToolsExtension ? window.devToolsExtension() : undefined window.devToolsExtension ? window.devToolsExtension() : undefined
) )
const localInput = localStore.get('dubdiff')
if (localInput.input) {
//dispatch localStore data to store
//should this be done after the first render?
}
//save the state whenever the state changes //save the state whenever the state changes
function saveState() { function saveState() {
let state = store.getState() let state = store.getState()

View File

@ -1,7 +1,10 @@
export const resetInput = () => ({ type: 'RESET_INPUT' })
export const updateOriginalInput = (text) => ({ type: 'UPDATE_ORIGINAL_INPUT', data:text}) export const updateOriginalInput = (text) => ({ type: 'UPDATE_ORIGINAL_INPUT', data:text})
export const updateFinalInput = (text) => ({ type: 'UPDATE_FINAL_INPUT', data:text}) export const updateFinalInput = (text) => ({ type: 'UPDATE_FINAL_INPUT', data:text})
export const clearInput = () => ({ type: 'CLEAR_INPUT'})
export const updateOriginalCompare = (text) => ({ type: 'UPDATE_ORIGINAL_COMPARE', data:text})
export const updateFinalCompare = (text) => ({ type: 'UPDATE_FINAL_COMPARE', data:text})
export const clearCompare = () => ({ type: 'CLEAR_COMPARE'})
export const setPlaintextFormat = () => ({ type: 'SET_PLAINTEXT_FORMAT'}) export const setPlaintextFormat = () => ({ type: 'SET_PLAINTEXT_FORMAT'})
export const setMarkdownFormat = () => ({ type: 'SET_MARKDOWN_FORMAT'}) export const setMarkdownFormat = () => ({ type: 'SET_MARKDOWN_FORMAT'})
export const showOriginal = () => ({ type: 'SHOW_ORIGINAL'}) export const showOriginal = () => ({ type: 'SHOW_ORIGINAL'})

View File

@ -17,7 +17,7 @@ const mapStateToProps = (state) => ({
isShowOriginal: Selectors.isShowOriginal(state), isShowOriginal: Selectors.isShowOriginal(state),
isShowFinal: Selectors.isShowFinal(state), isShowFinal: Selectors.isShowFinal(state),
isShowDifference: Selectors.isShowDifference(state), isShowDifference: Selectors.isShowDifference(state),
safeInput: Selectors.safeInput(state), compare: state.compare,
diff: Selectors.diff(state) diff: Selectors.diff(state)
}) })
@ -45,7 +45,7 @@ class Compare extends React.Component {
<ShowPlaintext diff={this.props.diff} isMarkdownFormat={this.props.isMarkdownFormat}>{this.props.diff}</ShowPlaintext>: <ShowPlaintext diff={this.props.diff} isMarkdownFormat={this.props.isMarkdownFormat}>{this.props.diff}</ShowPlaintext>:
<ShowPlaintext <ShowPlaintext
text={this.props.isShowOriginal? this.props.safeInput.original: this.props.safeInput.final} text={this.props.isShowOriginal? this.props.compare.original: this.props.compare.final}
isMarkdownFormat={this.props.isMarkdownFormat} isMarkdownFormat={this.props.isMarkdownFormat}
/> />
} }

View File

@ -12,6 +12,7 @@ const mapStateToProps = (state) => ({
isShowOriginal: Selectors.isShowOriginal(state), isShowOriginal: Selectors.isShowOriginal(state),
isShowFinal: Selectors.isShowFinal(state), isShowFinal: Selectors.isShowFinal(state),
isShowDifference: Selectors.isShowDifference(state), isShowDifference: Selectors.isShowDifference(state),
compare: state.compare
}) })
@ -21,13 +22,17 @@ const mapDispatchToProps = dispatch => ({
onShowOriginal: () => dispatch(Actions.showOriginal()), onShowOriginal: () => dispatch(Actions.showOriginal()),
onShowFinal: () => dispatch(Actions.showFinal()), onShowFinal: () => dispatch(Actions.showFinal()),
onShowDifference: () => dispatch(Actions.showDifference()), onShowDifference: () => dispatch(Actions.showDifference()),
onEdit: (compare) => {
dispatch(Actions.updateOriginalInput(compare.original))
dispatch(Actions.updateFinalInput(compare.final))
dispatch(Actions.clearCompare())
}
}) })
class CompareControls extends React.Component { class CompareControls extends React.Component {
onClickCompare() { onClickEdit() {
this.props.onEdit(this.props.compare)
} }
onClickMarkdownFormat() { onClickMarkdownFormat() {
@ -41,6 +46,10 @@ class CompareControls extends React.Component {
render() { render() {
return ( return (
<Segment.Group> <Segment.Group>
<Segment>
<Link to="/"><Button fluid onClick={this.onClickEdit.bind(this)}>Edit</Button></Link>
</Segment>
<Segment > <Segment >
<Button fluid onClick={this.props.onShowOriginal} active={this.props.isShowOriginal}>Original</Button> <Button fluid onClick={this.props.onShowOriginal} active={this.props.isShowOriginal}>Original</Button>
<Button fluid onClick={this.props.onShowFinal} active={this.props.isShowFinal}>Final</Button> <Button fluid onClick={this.props.onShowFinal} active={this.props.isShowFinal}>Final</Button>

View File

@ -1,13 +1,26 @@
import React from 'react' import React from 'react'
import {connect} from 'react-redux'
import {Segment, Header} from 'semantic-ui-react' import {Segment, Header} from 'semantic-ui-react'
import {Link} from 'react-router' import {Link} from 'react-router'
import * as Actions from '../actions'
const mapStateToProps = (state) => ({
})
const mapDispatchToProps = dispatch => ({
onClear: () => {
dispatch(Actions.clearInput())
dispatch(Actions.clearCompare())
},
})
const SiteHeader = (props) => ( const SiteHeader = (props) => (
<Segment basic padded textAlign="center" as="header" id='masthead'> <Segment basic padded textAlign="center" as="header" id='masthead'>
<Header><Link to="/">dubdiff</Link></Header> <Link to="/"><Header onClick={props.onClear}>dubdiff</Header></Link>
</Segment> </Segment>
) )
export default SiteHeader export default connect(mapStateToProps, mapDispatchToProps)(SiteHeader)

View File

@ -9,19 +9,29 @@ import * as Selectors from '../selectors'
const mapStateToProps = (state) => ({ const mapStateToProps = (state) => ({
format: state.format, format: state.format,
isMarkdownFormat: Selectors.isMarkdownFormat(state) isMarkdownFormat: Selectors.isMarkdownFormat(state),
safeInput: Selectors.safeInput(state)
}) })
const mapDispatchToProps = dispatch => ({ const mapDispatchToProps = dispatch => ({
onSetPlaintextFormat: (format) => dispatch(Actions.setPlaintextFormat()), onSetPlaintextFormat: (format) => dispatch(Actions.setPlaintextFormat()),
onSetMarkdownFormat: (format) => dispatch(Actions.setMarkdownFormat()) onSetMarkdownFormat: (format) => dispatch(Actions.setMarkdownFormat()),
onCompare: (safeInput) => {
dispatch(Actions.updateOriginalCompare(safeInput.original))
dispatch(Actions.updateFinalCompare(safeInput.final))
dispatch(Actions.clearInput())
}
}) })
class MainControls extends React.Component { class MainControls extends React.Component {
onClickCompare() { onClickCompare() {
//generate new id? (or should the id be baked into the link route?)
//post safeInput to db
this.props.onCompare(this.props.safeInput)
return false
} }
onClickMarkdownFormat() { onClickMarkdownFormat() {
@ -36,7 +46,7 @@ class MainControls extends React.Component {
return ( return (
<Segment.Group> <Segment.Group>
<Segment > <Segment >
<Link to="compare"><Button fluid>Compare</Button></Link> <Link to="compare"><Button fluid onClick={this.onClickCompare.bind(this)}>Compare</Button></Link>
</Segment> </Segment>
<Segment > <Segment >

View File

@ -17,10 +17,10 @@ const ShowPlaintext = (props) => {
export default ShowPlaintext export default ShowPlaintext
function diffToPre(diff) { function diffToPre(diff) {
return diff.map(part => ( return diff.map((part, index) => (
part.added ? <span><ins>{part.value}</ins>{ifNotNewlineSpace(part.value)}</span> : part.added ? <span key={index}><ins>{part.value}</ins>{ifNotNewlineSpace(part.value)}</span> :
part.removed ? <span><del>{part.value}</del>{ifNotNewlineSpace(part.value)}</span> : part.removed ? <span key={index}><del>{part.value}</del>{ifNotNewlineSpace(part.value)}</span> :
<span>{part.value}{ifNotNewlineSpace(part.value)}</span> <span key={index}>{part.value}{ifNotNewlineSpace(part.value)}</span>
)) ))
} }

View File

@ -1,18 +1,33 @@
export function input (state, action ) { export function input (state, action ) {
switch (action.type) { switch (action.type) {
case 'UPDATE_ORIGINAL_INPUT': case 'UPDATE_ORIGINAL_INPUT':
return Object.assign({}, state, {original:action.data}) return Object.assign({}, state, {original:action.data})
case 'UPDATE_FINAL_INPUT': case 'UPDATE_FINAL_INPUT':
return Object.assign({}, state, {final:action.data}) return Object.assign({}, state, {final:action.data})
case 'RESET_INPUT': case 'CLEAR_INPUT':
return {original:'', final:''} return {original:'', final:''}
default: default:
return state || {original:'', final:''} return state || {original:'', final:''}
} }
} }
export function compare (state, action ) {
switch (action.type) {
case 'UPDATE_ORIGINAL_COMPARE':
return Object.assign({}, state, {original:action.data})
case 'UPDATE_FINAL_COMPARE':
return Object.assign({}, state, {final:action.data})
case 'CLEAR_COMPARE':
return {original:'', final:''}
default:
return state || {original:'', final:''}
}
}
export const Format = { export const Format = {
PLAINTEXT: 'PLAINTEXT', PLAINTEXT: 'PLAINTEXT',
MARKDOWN: 'MARKDOWN' MARKDOWN: 'MARKDOWN'

View File

@ -5,8 +5,8 @@ import Main from './components/Main'
import Compare from './components/Compare' import Compare from './components/Compare'
var routes = [ var routes = [
<Route path="/" component={Main}/>, <Route key="root" path="/" component={Main}/>,
<Route path="/:compareId" component={Compare}/> <Route key="compare" path="/:compareId" component={Compare}/>
] ]

View File

@ -13,6 +13,7 @@ import * as Dubdiff from './util/dubdiff'
const input = (state) => state.input const input = (state) => state.input
const format = (state) => state.format const format = (state) => state.format
const show = (state) => state.show const show = (state) => state.show
const compare = (state) => state.compare
export const safeInput = createSelector( export const safeInput = createSelector(
[input], [input],
@ -42,9 +43,9 @@ export const isShowDifference= isShow(Show.DIFFERENCE)
export const diff = createSelector( export const diff = createSelector(
[format, input], [format, compare],
(format, input) => { (format, compare) => {
return Dubdiff.plaintextDiff(input.original, input.final) return Dubdiff.plaintextDiff(compare.original, compare.final)
/* /*
let diff = JsDiff.diffWords (input.original.replace(/ /g, ' '), input.final.replace(/ /g, ' ')) let diff = JsDiff.diffWords (input.original.replace(/ /g, ' '), input.final.replace(/ /g, ' '))
return diff.map(({added, removed, value})=>({added, removed, value:value.replace(/ /g, ' ')})).map(part => ( return diff.map(({added, removed, value})=>({added, removed, value:value.replace(/ /g, ' ')})).map(part => (

View File

@ -1,9 +1,12 @@
import * as JsDiff from 'diff' import * as JsDiff from 'diff'
//!!! this deal with adding and removing spaces could be done more elegantly by
// diffing on an array of simple data structures that contain the text and the adjacent space
// the diff would use a custom compare function that would disregard the spaces
// alternately, the text could be split with the spaces included in the array and then compared with a
// custom diff function that would treat the space elements as null/ignored
export function plaintextDiff(original, final) { export function plaintextDiff(original, final) {
let arrOriginal = plaintextSplit(original) let arrOriginal = plaintextSplit(original)
let arrFinal = plaintextSplit(final) let arrFinal = plaintextSplit(final)
@ -11,13 +14,17 @@ export function plaintextDiff(original, final) {
diff = plaintextRestoreSpaces(diff) diff = plaintextRestoreSpaces(diff)
return diff return diff
}
// return JsDiff.diffWordsWithSpace(original,final) export function markdownDiff(original, final) {
let arrOriginal = plaintextSplit(original)
let arrFinal = plaintextSplit(final)
let diff = JsDiff.diffArrays(arrOriginal, arrFinal)
diff = plaintextRestoreSpaces(diff)
diff = rewriteMarkdownDiff(diff)
// let diff = JsDiff.diffLines(original.replace(/ /g, '###\n'), final.replace(/ /g, '###\n')) return diff
// console.log(diff, diff.map(({added, removed, value})=>({added, removed, value:value.replace(/###\n/g, ' ')})))
// return diff.map(({added, removed, value})=>({added, removed, value:value.replace(/###\n/g, ' ')}))
} }
@ -38,3 +45,198 @@ function plaintextRestoreSpaces (diff) {
).join('') ).join('')
})) }))
} }
// Rewrites the given diff to correctly render as markdown, assuming the source
// documents were also valid markdown.
// In essence, moves the markdown formatting elements in or out of the inserted and deleted blocks, as appropriate
//rules:
// 1. if a multiline del block is followed by an ins block,
// the first line of the ins block should be inserted at the end of the first line of the del block
// so the markdown will apply to the ins text as it should
// 2. after a newline, if an ins or del block begins with a markdown line formatting prefix (eg. for a title or list)
// then that prefix should be moved out of the block
//not yet implemented rules:
// 3. if an ins or del block spans one half of a bold, italic or link string
// eg. **Hello <del>World** I</del><ins>Darling** she</ins> said
// the block should be broken up to move the formatting code outside
// OR the whole formatting string could be brought into the block
// eg. **Hello <del>World</del><ins>Darling</ins>** <ins>I</ins><del>she</del> said
function rewriteMarkdownDiff(diff) {
//apply transformation rules
let transformedDiff = diff
transformedDiff= applyTransformationRule1(transformedDiff)
//transformedDiff= applyTransformationRule2(transformedDiff)
return transformedDiff
}
//Transformation rule 1
// 1. if a multiline del block is followed by an ins block,
// the first line of the ins block should be inserted at the end of the first line of the del block
// so the markdown will apply to the ins text as it should
function applyTransformationRule1(diff) {
let transformedDiff = []
const B_ADD='added', B_REM='removed', B_SAME='same'
let previousBlockType = null
let currentBlockType = null
let previousBlockWasMultiline = false
let currentBlockIsMultiline = false
//iterate the input tokens to create the intermediate representation
diff.forEach((currentBlock) => {
previousBlockType = currentBlockType
previousBlockWasMultiline = currentBlockIsMultiline
currentBlockType = (currentBlock.added ? B_ADD : (currentBlock.removed ? B_REMOVED : B_SAME))
currentBlockIsMultiline = isMultilineDiffBlock(currentBlock)
//transform rule 1 applys when:
// the previous block was a del and had multiple lines
// the current block is an ins
if (previousBlockType == B_REM && currentBlockType == B_INS && previousBlockWasMultiline) {
//split the first line from the current block
let currentBlockSplit = splitMultilineDiffBlock(currentBlock)
//pop the previous diff entry
let previousBlock = transformedDiff.pop()
//split the first line from the previous block
let previousBlockSplit = splitMultilineDiffBlock(currentBlock)
//now add the blocks back, interleaving del and ins blocks
for (let i=0; i<Math.max(previousBlockSplit.length, currentBlockSplit.length); i++) {
if (i<previousBlockSplit.length)
transformedDiff.push(previousBlockSplit[i])
if (i<currentBlockSplit.length)
transformedDiff.push(currentBlockSplit[i])
}
}
else {
//otherwise, we just add the current block to the transformed list
transformedDiff.push(currentBlock)
}
})
return transformedDiff
}
function applyTransformationRule2(diff) {
// we need to find markdown prefixes that should be pulled out of added/removed blocks to the start of the line
// prefixes are matched as follows:
// ^ - start of line
// ([ \t]*\>)* - blockquotes (possibly nested)
// (
// ([ \t]*#*) - headers
// |([ \t]+[\*\+-]) - unordered lists
// |([ \t]+[0-9]+\.) - numeric lists
// )?
// [ \t]* - trailing whitespace
const PREFIX = /^([ \t]*\>)*(([ \t]*#*)|([ \t]*[\*\+-])|([ \t]*[\d]+\.))?[ \t]*/
let transformedDiff = []
return transformedDiff
/// ...
transform.forEach(function(item) {
//newlines are undecorated
if (item.string == '\n') {
output += '\n';
//flag the new line
newline = true;
//and record the offset in the output string
newlineIndex = output.length;
return
}
//wrap del strings with tags
if (item.state == SDEL) {
output += '<del>' + item.string + '</del>';
//del doesn't reset the newline state
}
//ins strings have to be handled a little differently:
//if this is an ins just after a newline, or after a del after a newline,
//we need to peel off any markdown formatting prefixes and insert them at the beginning of the line outside the del/ins tags
else if (item.state == SINS && newline) {
var prestring, poststring;
var match = item.string.match(PREFIX);
if (match == null)
prestring ="";
else
prestring = match[0];
poststring = item.string.substring(prestring.length);
output = output.substring(0, newlineIndex) + prestring + output.substring(newlineIndex);
output += '<ins>' + poststring + '</ins>';
newline = false;
newlineIndex = -1;
}
else if (item.state == SINS) {
output += '<ins>' + item.string + '</ins>';
}
//and just output other strings
else {
output += item.string;
//this resets the newline state
newline = false;
newlineIndex = -1;
}
});
}
//returns true if the given diff block contains a newline element
function isMultilineDiffBlock({value}) {
return value.find(word => word == '\n')
}
//returns an array of diff blocks that have the same added, removed fields as the given one
//but with the array of words split by newlines
//if the diff block has no newlines, an array containing only that diff will be returned
//if the diff block has newlines, the resulting array will have a series of blocks,
// each of which subsequent to the first block will begin with a newline
//if the diff block begins with a newline, the returned array will begin with an empty diff
function splitMultilineDiffBlock({added, removed, value}) {
//find the indices of the diff block that coorespond to newlines
const splits = findIndicesOf(value, '\n')
//create a range from each index
const ranges = splits.reduce(
//the accumulator is a structure with the last index and the list of ranges
//the ranges are a {start, end} structure
({last, ranges}, i) => {i, ranges.concat([{start:last, end:i}])},
//start with the zero index and an empty array
{last: 0, ranges:[]}
).ranges
//map the ranges into blocks
const blocks = ranges.map(
//each block is the same as the given original block, but with the values split at newlines
({start, end}) => {added, removed, value.slice(start, end)}
)
return blocks
}
//collect all the indices of the given array that satisfy the test function
const findIndicesOf = (array, test) => array.reduce(
//add indexes that satisfy the test function to the array
(acc, x, i) => (test(x) ? acc.concat([i]) : acc ),
//start with the empty array
[]
)

View File

@ -34,7 +34,7 @@ export default function render(store, req, res) {
} }
} else { } else {
res.status(404).send(renderError('Not found', '')) res.status(404).send(errorTemplate('Not found', `${req.url} not found.`))
} }
}) })
} }
@ -47,7 +47,7 @@ const pageTemplate = (body) => {
<title>Dubdiff</title> <title>Dubdiff</title>
<!-- CSS --> <!-- CSS -->
<link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.2.2/semantic.min.css"/> <link rel="stylesheet" href="dist/semantic.min.css"/>
<link rel="stylesheet" href="dist/main.css"/> <link rel="stylesheet" href="dist/main.css"/>
<!-- Favicon --> <!-- Favicon -->
@ -65,7 +65,10 @@ function errorTemplate(title, message, exception) {
return pageTemplate(` return pageTemplate(`
<h1>${title}</h1> <h1>${title}</h1>
<p>${message}</p> <p>${message}</p>
<pre>${exception.toString()}</pre> ${exception ?
`<pre>${exception.toString()}</pre>`:
``
}
`) `)
} }