mirror of
https://github.com/ueberdosis/tiptap.git
synced 2024-12-14 18:49:02 +08:00
Merge branch 'feature/collab-plugin'
# Conflicts: # yarn.lock
This commit is contained in:
commit
0ee4560265
@ -56,5 +56,7 @@ module.exports = {
|
||||
'class-methods-use-this': 'off',
|
||||
|
||||
'global-require': 'off',
|
||||
|
||||
'func-names': ['error', 'never'],
|
||||
}
|
||||
}
|
||||
|
@ -4,5 +4,6 @@ module.exports = {
|
||||
],
|
||||
plugins: [
|
||||
'@babel/plugin-syntax-dynamic-import',
|
||||
'@babel/plugin-proposal-class-properties',
|
||||
],
|
||||
}
|
||||
|
123
examples/Components/Routes/Collaboration/index.vue
Normal file
123
examples/Components/Routes/Collaboration/index.vue
Normal 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>
|
@ -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>
|
||||
|
@ -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";
|
||||
|
@ -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'),
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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",
|
||||
|
78
packages/tiptap-extensions/src/extensions/Collaboration.js
Normal file
78
packages/tiptap-extensions/src/extensions/Collaboration.js
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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'
|
||||
|
||||
|
@ -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
|
||||
|
57
packages/tiptap/src/Utils/Emitter.js
Normal file
57
packages/tiptap/src/Utils/Emitter.js
Normal 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
|
||||
}
|
||||
}
|
@ -7,6 +7,14 @@ export default class Extension {
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
return null
|
||||
}
|
||||
|
||||
bindEditor(editor = null) {
|
||||
this.editor = editor
|
||||
}
|
||||
|
||||
get name() {
|
||||
return null
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
3
packages/tiptap/src/Utils/camelCase.js
Normal file
3
packages/tiptap/src/Utils/camelCase.js
Normal 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, '')
|
||||
}
|
@ -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'
|
||||
|
Loading…
Reference in New Issue
Block a user