Initial working version (#1)

* wip: initial up

* chore: package settings

* docs: base

* refactor: initialize, unused func

* wip: initial add

* test: cov
This commit is contained in:
Marc Cataford 2020-04-19 20:14:51 -04:00 committed by GitHub
parent 4b92083ecf
commit 2e018d3510
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 4722 additions and 1 deletions

7
.eslintrc.js Normal file
View file

@ -0,0 +1,7 @@
module.exports = {
extends: [
'@tophat/eslint-config/base',
'@tophat/eslint-config/jest',
'@tophat/eslint-config/web',
]
}

View file

@ -1 +1,32 @@
# draft-js-commit-log-plugin
`draft-js-commit-log-plugin` is an exploration of granular change-tracking in DraftJS. The usual approach to saving `draft-js` document is to serialize and store the document as a whole on save, which limits you to whole-document updates instead of a model where you would apply changes in sequence on a "branch" like you would on `git`. The plugin implements a change-tracking layer in DraftJS so that in-editor actions that analyzes changes pushed to the DraftJS state and forms sequential commits from them. It also tags all editor blocks with a stable, unique identifier so that changes can be consistently attached to them through saves.
## Install
You can install the plugin package from this repository by cloning and packing it yourself. It is not yet released on `npm`.
Given that you have `draft-js-plugin-editor` set up, you can simply do the following:
```js
// What you should already have
import React, { useState } from 'react'
import { Editor } from 'draft-js-plugin-editor'
import { EditorState } from 'draft-js'
// What this package offers
import createCommitLogPlugin from 'draft-js-commit-log-plugin'
const commitLogPlugin = createCommitLogPlugin()
const App = () => {
const [editorState, setEditorState] = useState(EditorState.createEmpty())
return (
<Editor
editorState={editorState}
plugins={[commitLogPlugin]}
onChange={s => setEditorState(s)}
/>
)
}
```

35
package.json Normal file
View file

@ -0,0 +1,35 @@
{
"name": "draft-js-commit-log-plugin",
"description": "A commitlog-like plugin that simplifies tracking changes in DraftJS documents",
"version": "0.0.0",
"main": "./src/index.js",
"repository": "git@github.com:mcataford/draft-js-commit-log-plugin.git",
"author": "Marc Cataford <c.marcandre@gmail.com>",
"license": "MIT",
"files": ["src/*.js", "!*.test.js"],
"scripts": {
"lint": "eslint ./src",
"test": "jest ./src"
},
"devDependencies": {
"@tophat/eslint-config": "^0.6.1",
"draft-js": "^0.11.5",
"eslint": "^6.8.0",
"eslint-config-prettier": "^6.10.1",
"eslint-plugin-import": "^2.20.2",
"eslint-plugin-jest": "^23.8.2",
"eslint-plugin-prettier": "^3.1.3",
"jest": "^25.3.0",
"prettier": "^2.0.4",
"react": "^16.13.1",
"react-dom": "^16.13.1"
},
"peerDependencies": {
"draft-js": "^0.11.5",
"react": "^16.13.1",
"react-dom": "^16.13.1"
},
"dependencies": {
"uuid": "^7.0.3"
}
}

3
src/index.js Normal file
View file

@ -0,0 +1,3 @@
const createCommitLogPlugin = require('./plugin')
module.exports = createCommitLogPlugin

122
src/index.test.js Normal file
View file

@ -0,0 +1,122 @@
const uuid = require('uuid/v4')
const { EditorState, Modifier, ContentState } = require('draft-js')
jest.mock('uuid/v4')
const createCommitLogPlugin = require('.')
describe('', () => {
let state
let pluginInstance
let c = 0
beforeEach(() => {
state = EditorState.createEmpty()
pluginInstance = createCommitLogPlugin()
jest.restoreAllMocks()
uuid.mockImplementation(() => ++c)
})
describe('Creating blocks', () => {
it('pushes an add for the initial block', () => {
pluginInstance.onChange(state)
const commitLog = pluginInstance.getCommitLog()
expect(commitLog).toHaveLength(1)
expect(commitLog[0].type).toEqual('add')
})
it('via split-block', () => {
const initialContent = state.getCurrentContent()
const withSplit = Modifier.splitBlock(
initialContent,
state.getSelection(),
)
const newState = EditorState.push(state, withSplit, 'split-block')
pluginInstance.onChange(newState)
const commitLog = pluginInstance.getCommitLog()
expect(commitLog).toHaveLength(2)
expect(commitLog[1].type).toEqual('add')
})
})
describe('Editing blocks', () => {
it('via insert-character', () => {
const initialContent = state.getCurrentContent()
const withSplit = Modifier.insertText(
initialContent,
state.getSelection(),
'',
)
const newState = EditorState.push(state, withSplit, 'split-block')
const newStateProcessed = pluginInstance.onChange(newState)
const withText = Modifier.replaceText(
newStateProcessed.getCurrentContent(),
newStateProcessed.getSelection(),
'yeet',
)
const newState2 = EditorState.push(
newStateProcessed,
withText,
'insert-characters',
)
pluginInstance.onChange(newState2)
const commitLog = pluginInstance.getCommitLog()
expect(commitLog).toHaveLength(2)
expect(commitLog[1].type).toEqual('edit')
expect(commitLog[1].text).toEqual('yeet')
})
// it('via backspace-character', () => {})
})
describe('Deleting blocks', () => {
it('via backspace-character', () => {
const initialContent = state.getCurrentContent()
const withSplit = Modifier.insertText(
initialContent,
state.getSelection(),
'',
)
const newState = EditorState.push(state, withSplit, 'split-block')
const newStateProcessed = pluginInstance.onChange(newState)
const withoutBlock = ContentState.createFromBlockArray([])
const newState2 = EditorState.push(
newStateProcessed,
withoutBlock,
'backspace-character',
)
pluginInstance.onChange(newState2)
const commitLog = pluginInstance.getCommitLog()
expect(commitLog).toHaveLength(2)
expect(commitLog[1].type).toEqual('delete')
expect(commitLog[0].tag).toEqual(commitLog[1].tag)
})
})
it('unrecognized changes do not produce commits', () => {
pluginInstance.onChange(state)
const initialContent = state.getCurrentContent()
const withSplit = Modifier.insertText(
initialContent,
state.getSelection(),
'',
)
const newState = EditorState.push(state, withSplit, 'yeet-block')
pluginInstance.onChange(newState)
// The only change is the initial add from onChange
expect(pluginInstance.getCommitLog()).toHaveLength(1)
})
it('initialize sets up the state', () => {
pluginInstance.initialize()
expect(pluginInstance.pluginState).toEqual({
changeIndex: 0,
commits: [],
previousState: null,
})
})
})

41
src/plugin.js Normal file
View file

@ -0,0 +1,41 @@
const { deriveChangesFromStates, tagUntaggedBlocks } = require('./utils')
const createCommitLogPlugin = () => {
const pluginState = {
commits: [],
previousState: null,
changeIndex: 0,
}
const initialize = () => {}
const updatePluginState = ({ newCommits, previousState }) => {
pluginState.commits = pluginState.commits.concat(newCommits)
pluginState.previousState = previousState
pluginState.changeIndex += 1
}
const getCommitLog = () => {
return pluginState.commits
}
const onChange = editorState => {
const prevState = pluginState.previousState
const nextState = tagUntaggedBlocks(editorState)
const newCommits = deriveChangesFromStates(prevState, nextState)
updatePluginState({ newCommits, previousState: nextState })
// TODO: remove when hardening
// eslint-disable-next-line no-console
console.table(pluginState.commits)
return nextState
}
return {
initialize,
onChange,
getCommitLog,
pluginState,
}
}
module.exports = createCommitLogPlugin

130
src/utils.js Normal file
View file

@ -0,0 +1,130 @@
const uuid = require('uuid/v4')
const { ContentState, EditorState } = require('draft-js')
const Immutable = require('immutable')
function reconcileTags(previousBlockMap, currentBlockMap) {
return previousBlockMap.valueSeq().map(block => {
const isTagged = block.getData().has('tag')
if (isTagged) return block
const key = block.getKey()
const currentBlock = currentBlockMap.get(key)
if (!currentBlock) return block
const newBlockData = block
.getData()
.set('tag', currentBlock.getData().get('tag'))
return isTagged ? block : block.set('data', newBlockData)
})
}
function processBlockDeletion(previousBlocks, currentBlocks) {
const deletedBlocks = previousBlocks
.valueSeq()
.filter(block => !currentBlocks.has(block.getData().get('tag')))
return deletedBlocks
.map(block => ({
type: 'delete',
tag: block.getData().get('tag'),
}))
.toJS()
}
function processBlockCreation(previousBlocks, currentBlocks) {
const addedBlocks = currentBlocks
.valueSeq()
.filter(block => !previousBlocks.has(block.getData().get('tag')))
return addedBlocks
.map(block => ({
type: 'add',
tag: block.getData().get('tag'),
text: block.getText(),
}))
.toJS()
}
function processBlockEdit(previousBlocks, currentBlocks) {
const editedBlocks = currentBlocks.valueSeq().filter(block => {
const currentTag = block.getData().get('tag')
const blockBefore = previousBlocks.get(currentTag)
if (!currentTag || !blockBefore) return false
const isTextDifferent = block.getText() !== blockBefore.getText()
const isBlockCommon = !!blockBefore
return isTextDifferent && isBlockCommon
})
return editedBlocks
.map(block => ({
type: 'edit',
tag: block.getData().get('tag'),
text: block.getText(),
}))
.toJS()
}
function deriveChangesFromStates(previous, actual) {
const changeType = actual.getLastChangeType()
const currentBlocks = getBlockMapByTag(
actual.getCurrentContent().getBlockMap().valueSeq().toList(),
)
const previousBlocks = previous
? getBlockMapByTag(
reconcileTags(
previous.getCurrentContent().getBlockMap(),
actual.getCurrentContent().getBlockMap(),
).toList(),
)
: new Immutable.Map()
const hasLessBlocks = previousBlocks.size > currentBlocks.size
if (
changeType === 'insert-characters' ||
(changeType === 'backspace-character' && !hasLessBlocks)
) {
return processBlockEdit(previousBlocks, currentBlocks)
} else if (changeType === 'split-block') {
return processBlockCreation(previousBlocks, currentBlocks)
} else if (changeType === 'backspace-character' && hasLessBlocks) {
return processBlockDeletion(previousBlocks, currentBlocks)
}
// TODO: Clear up initial block creation.
if (previousBlocks.size === 0)
return processBlockCreation(previousBlocks, currentBlocks)
return []
}
function tagUntaggedBlocks(editorState) {
const contentState = editorState.getCurrentContent()
const blockList = contentState.getBlockMap().valueSeq()
const taggedBlockList = blockList.map(block => {
const isTagged = block.getData().has('tag')
const newBlockData = block.getData().set('tag', uuid())
return isTagged ? block : block.set('data', newBlockData)
})
const updatedContent = ContentState.createFromBlockArray(
taggedBlockList.toArray(),
contentState.getEntityMap(),
)
return EditorState.set(editorState, { currentContent: updatedContent })
}
function getBlockMapByTag(blocks) {
return blocks.reduce((blockTagMap, block) => {
const tag = block.getData().get('tag')
if (!tag || !block) return blockTagMap
return blockTagMap.set(tag, block)
}, new Immutable.Map())
}
module.exports = {
tagUntaggedBlocks,
deriveChangesFromStates,
}

4352
yarn.lock Normal file

File diff suppressed because it is too large Load diff