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:
parent
4b92083ecf
commit
2e018d3510
8 changed files with 4722 additions and 1 deletions
7
.eslintrc.js
Normal file
7
.eslintrc.js
Normal file
|
@ -0,0 +1,7 @@
|
|||
module.exports = {
|
||||
extends: [
|
||||
'@tophat/eslint-config/base',
|
||||
'@tophat/eslint-config/jest',
|
||||
'@tophat/eslint-config/web',
|
||||
]
|
||||
}
|
33
README.md
33
README.md
|
@ -1 +1,32 @@
|
|||
# draft-js-commit-log-plugin
|
||||
# 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
35
package.json
Normal 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
3
src/index.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
const createCommitLogPlugin = require('./plugin')
|
||||
|
||||
module.exports = createCommitLogPlugin
|
122
src/index.test.js
Normal file
122
src/index.test.js
Normal 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
41
src/plugin.js
Normal 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
130
src/utils.js
Normal 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,
|
||||
}
|
Reference in a new issue