Merge branch 'feature/collab-plugin'

# Conflicts:
#	yarn.lock
This commit is contained in:
Philipp Kühn 2019-05-06 10:16:01 +02:00
commit 0ee4560265
18 changed files with 1139 additions and 942 deletions

View File

@ -56,5 +56,7 @@ module.exports = {
'class-methods-use-this': 'off',
'global-require': 'off',
'func-names': ['error', 'never'],
}
}

View File

@ -4,5 +4,6 @@ module.exports = {
],
plugins: [
'@babel/plugin-syntax-dynamic-import',
'@babel/plugin-proposal-class-properties',
],
}

View File

@ -0,0 +1,123 @@
<template>
<div class="editor">
<div class="message">
With the Collaboration Extension it's possible for several users to work on a document at the same time. To make this possible, client-side and server-side code is required. This example shows this using a <a href="https://glitch.com/edit/#!/tiptap-sockets" target="_blank">socket server</a>. Try it out below:
</div>
<template v-if="editor && !loading">
<div class="count">
{{ count }} {{ count === 1 ? 'user' : 'users' }} connected
</div>
<editor-content class="editor__content" :editor="editor" />
</template>
<em v-else>
Connecting to socket server
</em>
</div>
</template>
<script>
import io from 'socket.io-client'
import { Editor, EditorContent } from 'tiptap'
import {
HardBreak,
Heading,
Bold,
Code,
Italic,
History,
Collaboration,
} from 'tiptap-extensions'
export default {
components: {
EditorContent,
},
data() {
return {
loading: true,
editor: null,
socket: null,
count: 0,
}
},
methods: {
onInit({ doc, version }) {
this.loading = false
if (this.editor) {
this.editor.destroy()
}
this.editor = new Editor({
content: doc,
extensions: [
new HardBreak(),
new Heading({ levels: [1, 2, 3] }),
new Bold(),
new Code(),
new Italic(),
new History(),
new Collaboration({
// the initial version we start with
// version is an integer which is incremented with every change
version,
// debounce changes so we can save some bandwidth
debounce: 250,
// onSendable is called whenever there are changed we have to send to our server
onSendable: data => {
this.socket.emit('update', data)
},
}),
],
})
},
setCount(count) {
this.count = count
},
},
mounted() {
// server implementation: https://glitch.com/edit/#!/tiptap-sockets
this.socket = io('wss://tiptap-sockets.glitch.me')
// get the current document and its version
.on('init', data => this.onInit(data))
// send all updates to the collaboration extension
.on('update', data => this.editor.extensions.options.collaboration.update(data))
// get count of connected users
.on('getCount', count => this.setCount(count))
},
beforeDestroy() {
this.editor.destroy()
},
}
</script>
<style lang="scss">
@import "~variables";
.count {
display: flex;
align-items: center;
font-weight: bold;
color: rgba($color-black, 0.5);
color: #27b127;
margin-bottom: 1rem;
text-transform: uppercase;
font-size: 0.7rem;
line-height: 1;
&:before {
content: '';
display: inline-flex;
background-color: #27b127;
width: 0.4rem;
height: 0.4rem;
border-radius: 50%;
margin-right: 0.3rem;
}
}
</style>

View File

@ -45,6 +45,9 @@
<router-link class="subnavigation__link" to="/placeholder">
Placeholder
</router-link>
<router-link class="subnavigation__link" to="/collaboration">
Collaboration
</router-link>
<router-link class="subnavigation__link" to="/export">
Export HTML or JSON
</router-link>

View File

@ -74,6 +74,15 @@ h3 {
background-color: rgba($color-black, 0.1);
}
.message {
background-color: rgba($color-black, 0.05);
color: rgba($color-black, 0.7);
padding: 1rem;
border-radius: 6px;
margin-bottom: 1.5rem;
font-style: italic;
}
@import "./editor";
@import "./menubar";
@import "./menububble";

View File

@ -117,6 +117,13 @@ const routes = [
githubUrl: 'https://github.com/scrumpy/tiptap/tree/master/examples/Components/Routes/Placeholder',
},
},
{
path: '/collaboration',
component: () => import('Components/Routes/Collaboration'),
meta: {
githubUrl: 'https://github.com/scrumpy/tiptap/tree/master/examples/Components/Routes/Collaboration',
},
},
{
path: '/export',
component: () => import('Components/Routes/Export'),

View File

@ -26,6 +26,7 @@
"devDependencies": {
"@babel/core": "^7.4.4",
"@babel/node": "^7.2.2",
"@babel/plugin-proposal-class-properties": "^7.4.4",
"@babel/plugin-syntax-dynamic-import": "^7.2.0",
"@babel/plugin-transform-runtime": "^7.4.4",
"@babel/polyfill": "^7.4.4",
@ -88,5 +89,7 @@
"webpack-svgstore-plugin": "^4.1.0",
"zlib": "^1.0.5"
},
"dependencies": {}
"dependencies": {
"socket.io-client": "^2.2.0"
}
}

View File

@ -32,8 +32,8 @@ index = $pos.index(d)
// this is a copy of splitListItem
// see https://github.com/ProseMirror/prosemirror-schema-list/blob/master/src/schema-list.js
export default function splitListItem(itemType) {
return function _splitListItem(state, dispatch) {
export default function splitToDefaultListItem(itemType) {
return function (state, dispatch) {
const { $from, $to, node } = state.selection
if ((node && node.isBlock) || $from.depth < 2 || !$from.sameParent($to)) return false
const grandParent = $from.node(-1)

View File

@ -22,9 +22,11 @@
},
"dependencies": {
"lowlight": "^1.11.0",
"prosemirror-collab": "^1.1.1",
"prosemirror-history": "^1.0.4",
"prosemirror-state": "^1.2.2",
"prosemirror-tables": "^0.7.11",
"prosemirror-transform": "^1.1.3",
"prosemirror-utils": "^0.7.6",
"prosemirror-view": "^1.8.9",
"tiptap": "^1.17.0",

View File

@ -0,0 +1,78 @@
import { Extension } from 'tiptap'
import { Step } from 'prosemirror-transform'
import {
collab,
sendableSteps,
getVersion,
receiveTransaction,
} from 'prosemirror-collab'
export default class Collaboration extends Extension {
get name() {
return 'collaboration'
}
init() {
this.editor.on('update', ({ state }) => {
this.getSendableSteps(state)
})
}
get defaultOptions() {
return {
version: 0,
clientID: Math.floor(Math.random() * 0xFFFFFFFF),
debounce: 250,
onSendable: () => {},
update: ({ steps, version }) => {
const { state, view, schema } = this.editor
if (getVersion(state) > version) {
return
}
view.dispatch(receiveTransaction(
state,
steps.map(item => Step.fromJSON(schema, item.step)),
steps.map(item => item.clientID),
))
},
}
}
get plugins() {
return [
collab({
version: this.options.version,
clientID: this.options.clientID,
}),
]
}
getSendableSteps = this.debounce(state => {
const sendable = sendableSteps(state)
if (sendable) {
this.options.onSendable({
version: sendable.version,
steps: sendable.steps.map(step => step.toJSON()),
clientID: sendable.clientID,
})
}
}, this.options.debounce)
debounce(fn, delay) {
let timeout
return function (...args) {
if (timeout) {
clearTimeout(timeout)
}
timeout = setTimeout(() => {
fn(...args)
timeout = null
}, delay)
}
}
}

View File

@ -23,6 +23,7 @@ export { default as Link } from './marks/Link'
export { default as Strike } from './marks/Strike'
export { default as Underline } from './marks/Underline'
export { default as Collaboration } from './extensions/Collaboration'
export { default as History } from './extensions/History'
export { default as Placeholder } from './extensions/Placeholder'

View File

@ -12,12 +12,16 @@ import { keymap } from 'prosemirror-keymap'
import { baseKeymap } from 'prosemirror-commands'
import { inputRules, undoInputRule } from 'prosemirror-inputrules'
import { markIsActive, nodeIsActive, getMarkAttrs } from 'tiptap-utils'
import { ExtensionManager, ComponentView } from './Utils'
import {
camelCase, Emitter, ExtensionManager, ComponentView,
} from './Utils'
import { Doc, Paragraph, Text } from './Nodes'
export default class Editor {
export default class Editor extends Emitter {
constructor(options = {}) {
super()
this.defaultOptions = {
editorProps: {},
editable: true,
@ -41,6 +45,15 @@ export default class Editor {
onDrop: () => {},
}
this.events = [
'init',
'update',
'focus',
'blur',
'paste',
'drop',
]
this.init(options)
}
@ -69,7 +82,11 @@ export default class Editor {
}, 10)
}
this.options.onInit({
this.events.forEach(name => {
this.on(name, this.options[camelCase(`on ${name}`)])
})
this.emit('init', {
view: this.view,
state: this.state,
})
@ -105,7 +122,7 @@ export default class Editor {
return new ExtensionManager([
...this.builtInExtensions,
...this.options.extensions,
])
], this)
}
createPlugins() {
@ -217,20 +234,20 @@ export default class Editor {
createView() {
const view = new EditorView(this.element, {
state: this.state,
handlePaste: this.options.onPaste,
handleDrop: this.options.onDrop,
handlePaste: (...args) => { this.emit('paste', ...args) },
handleDrop: (...args) => { this.emit('drop', ...args) },
dispatchTransaction: this.dispatchTransaction.bind(this),
})
view.dom.style.whiteSpace = 'pre-wrap'
view.dom.addEventListener('focus', event => this.options.onFocus({
view.dom.addEventListener('focus', event => this.emit('focus', {
event,
state: this.state,
view: this.view,
}))
view.dom.addEventListener('blur', event => this.options.onBlur({
view.dom.addEventListener('blur', event => this.emit('blur', {
event,
state: this.state,
view: this.view,
@ -295,7 +312,7 @@ export default class Editor {
}
emitUpdate(transaction) {
this.options.onUpdate({
this.emit('update', {
getHTML: this.getHTML.bind(this),
getJSON: this.getJSON.bind(this),
state: this.state,
@ -325,6 +342,13 @@ export default class Editor {
this.view.dom.blur()
}
getSchemaJSON() {
return JSON.parse(JSON.stringify({
nodes: this.extensions.nodes,
marks: this.extensions.marks,
}))
}
getHTML() {
const div = document.createElement('div')
const fragment = DOMSerializer

View File

@ -0,0 +1,57 @@
export default class Emitter {
// Add an event listener for given event
on(event, fn) {
this._callbacks = this._callbacks || {}
// Create namespace for this event
if (!this._callbacks[event]) {
this._callbacks[event] = []
}
this._callbacks[event].push(fn)
return this
}
emit(event, ...args) {
this._callbacks = this._callbacks || {}
const callbacks = this._callbacks[event]
if (callbacks) {
callbacks.forEach(callback => callback.apply(this, args))
}
return this
}
// Remove event listener for given event. If fn is not provided, all event
// listeners for that event will be removed. If neither is provided, all
// event listeners will be removed.
off(event, fn) {
if (!this._callbacks || (arguments.length === 0)) {
this._callbacks = {}
return this
}
// specific event
const callbacks = this._callbacks[event]
if (!callbacks) {
return this
}
// remove all handlers
if (arguments.length === 1) {
delete this._callbacks[event]
return this
}
// remove specific handler
for (let i = 0; i < callbacks.length; i += 1) {
const callback = callbacks[i]
if (callback === fn) {
callbacks.splice(i, 1)
break
}
}
return this
}
}

View File

@ -7,6 +7,14 @@ export default class Extension {
}
}
init() {
return null
}
bindEditor(editor = null) {
this.editor = editor
}
get name() {
return null
}

View File

@ -2,7 +2,11 @@ import { keymap } from 'prosemirror-keymap'
export default class ExtensionManager {
constructor(extensions = []) {
constructor(extensions = [], editor) {
extensions.forEach(extension => {
extension.bindEditor(editor)
extension.init()
})
this.extensions = extensions
}

View File

@ -0,0 +1,3 @@
export default function (str) {
return str.replace(/(?:^\w|[A-Z]|\b\w)/g, (word, index) => (index === 0 ? word.toLowerCase() : word.toUpperCase())).replace(/\s+/g, '')
}

View File

@ -1,4 +1,6 @@
export { default as camelCase } from './camelCase'
export { default as ComponentView } from './ComponentView'
export { default as Emitter } from './Emitter'
export { default as Extension } from './Extension'
export { default as ExtensionManager } from './ExtensionManager'
export { default as Mark } from './Mark'

1728
yarn.lock

File diff suppressed because it is too large Load Diff