refactor collaboration example

This commit is contained in:
Hans Pagel
2020-11-30 14:12:19 +01:00
parent 6f3517a5cf
commit 442eba8c96
7 changed files with 51 additions and 429 deletions

View File

@@ -71,29 +71,33 @@
<button @click="setName"> <button @click="setName">
Set Name Set Name
</button> </button>
<button @click="changeName"> <button @click="updateCurrentUser({ name: getRandomName() })">
Random Name Random Name
</button> </button>
<button @click="changeColor"> <button @click="updateCurrentUser({ color: getRandomColor() })">
Random Color Random Color
</button> </button>
</div> </div>
<div class="collaboration-status">
{{ users.length }} user{{ users.length === 1 ? '' : 's' }}
</div>
<div class="collaboration-users"> <div class="collaboration-users">
<div <div
class="collaboration-users__item" class="collaboration-users__item"
:style="`background-color: ${user.color}`" :style="`background-color: ${otherUser.color}`"
v-for="user in users" v-for="otherUser in users"
:key="user.clientId" :key="otherUser.clientId"
> >
{{ user.name }} {{ otherUser.name }}
</div> </div>
</div> </div>
<editor-content :editor="editor" /> <editor-content :editor="editor" />
<div :class="`collaboration-status collaboration-status--${status}`">
<template v-if="status">
{{ status }},
</template>
{{ users.length }} user{{ users.length === 1 ? '' : 's' }} online
</div>
</div> </div>
</template> </template>
@@ -103,8 +107,13 @@ import Collaboration from '@tiptap/extension-collaboration'
import CollaborationCursor from '@tiptap/extension-collaboration-cursor' import CollaborationCursor from '@tiptap/extension-collaboration-cursor'
import * as Y from 'yjs' import * as Y from 'yjs'
import { WebrtcProvider } from 'y-webrtc' import { WebrtcProvider } from 'y-webrtc'
// import { WebsocketProvider } from 'y-websocket'
import { IndexeddbPersistence } from 'y-indexeddb' import { IndexeddbPersistence } from 'y-indexeddb'
const getRandomElement = list => {
return list[Math.floor(Math.random() * list.length)]
}
export default { export default {
components: { components: {
EditorContent, EditorContent,
@@ -112,18 +121,26 @@ export default {
data() { data() {
return { return {
name: this.getRandomName(), currentUser: {
color: this.getRandomColor(), name: this.getRandomName(),
color: this.getRandomColor(),
},
provider: null, provider: null,
indexdb: null, indexdb: null,
editor: null, editor: null,
users: [], users: [],
status: null,
} }
}, },
mounted() { mounted() {
const ydoc = new Y.Doc() const ydoc = new Y.Doc()
this.provider = new WebrtcProvider('tiptap-collaboration-example', ydoc) this.provider = new WebrtcProvider('tiptap-collaboration-example', ydoc)
// this.provider = new WebsocketProvider('ws://127.0.0.1:1234', 'tiptap-collaboration-example', ydoc)
this.provider.on('status', event => {
this.status = event.status
})
this.indexdb = new IndexeddbPersistence('tiptap-collaboration-example', ydoc) this.indexdb = new IndexeddbPersistence('tiptap-collaboration-example', ydoc)
this.editor = new Editor({ this.editor = new Editor({
@@ -134,10 +151,7 @@ export default {
}), }),
CollaborationCursor.configure({ CollaborationCursor.configure({
provider: this.provider, provider: this.provider,
user: { user: this.currentUser,
name: this.name,
color: this.color,
},
onUpdate: users => { onUpdate: users => {
this.users = users this.users = users
}, },
@@ -151,32 +165,19 @@ export default {
const name = window.prompt('Name') const name = window.prompt('Name')
if (name) { if (name) {
this.name = name return this.updateCurrentUser({
return this.updateUser() name,
})
} }
}, },
changeName() { updateCurrentUser(attributes) {
this.name = this.getRandomName() this.currentUser = { ...this.currentUser, ...attributes }
this.updateUser() this.editor.chain().focus().user(this.currentUser).run()
},
changeColor() {
this.color = this.getRandomColor()
this.updateUser()
},
updateUser() {
this.editor.chain().focus().user({
name: this.name,
color: this.color,
}).run()
// this.updateState()
}, },
getRandomColor() { getRandomColor() {
return this.getRandomElement([ return getRandomElement([
'#616161', '#616161',
'#A975FF', '#A975FF',
'#FB5151', '#FB5151',
@@ -189,14 +190,10 @@ export default {
}, },
getRandomName() { getRandomName() {
return this.getRandomElement([ return getRandomElement([
'Lea Thompson', 'Cyndi Lauper', 'Tom Cruise', 'Madonna', 'Jerry Hall', 'Joan Collins', 'Winona Ryder', 'Christina Applegate', 'Alyssa Milano', 'Molly Ringwald', 'Ally Sheedy', 'Debbie Harry', 'Olivia Newton-John', 'Elton John', 'Michael J. Fox', 'Axl Rose', 'Emilio Estevez', 'Ralph Macchio', 'Rob Lowe', 'Jennifer Grey', 'Mickey Rourke', 'John Cusack', 'Matthew Broderick', 'Justine Bateman', 'Lisa Bonet', 'Lea Thompson', 'Cyndi Lauper', 'Tom Cruise', 'Madonna', 'Jerry Hall', 'Joan Collins', 'Winona Ryder', 'Christina Applegate', 'Alyssa Milano', 'Molly Ringwald', 'Ally Sheedy', 'Debbie Harry', 'Olivia Newton-John', 'Elton John', 'Michael J. Fox', 'Axl Rose', 'Emilio Estevez', 'Ralph Macchio', 'Rob Lowe', 'Jennifer Grey', 'Mickey Rourke', 'John Cusack', 'Matthew Broderick', 'Justine Bateman', 'Lisa Bonet',
]) ])
}, },
getRandomElement(list) {
return list[Math.floor(Math.random() * list.length)]
},
}, },
beforeDestroy() { beforeDestroy() {
@@ -223,21 +220,27 @@ export default {
/* Some information about the status */ /* Some information about the status */
.collaboration-status { .collaboration-status {
background: #eee;
color: #666;
border-radius: 5px; border-radius: 5px;
padding: 0.5rem 1rem;
margin-top: 1rem; margin-top: 1rem;
color: #616161;
&::before { &::before {
content: ' '; content: ' ';
display: inline-block; display: inline-block;
width: 0.5rem; width: 0.5rem;
height: 0.5rem; height: 0.5rem;
background: green; background: #ccc;
border-radius: 50%; border-radius: 50%;
margin-right: 0.5rem; margin-right: 0.5rem;
} }
&--connecting::before {
background: #fd9170;
}
&--connected::before {
background: #9DEF8F;
}
} }
/* Give a remote user a caret */ /* Give a remote user a caret */

View File

@@ -1,354 +0,0 @@
<template>
<div>
<div v-if="editor">
<button @click="editor.chain().focus().toggleBold().run()" :class="{ 'is-active': editor.isActive('bold') }">
bold
</button>
<button @click="editor.chain().focus().toggleItalic().run()" :class="{ 'is-active': editor.isActive('italic') }">
italic
</button>
<button @click="editor.chain().focus().toggleStrike().run()" :class="{ 'is-active': editor.isActive('strike') }">
strike
</button>
<button @click="editor.chain().focus().toggleCode().run()" :class="{ 'is-active': editor.isActive('code') }">
code
</button>
<button @click="editor.chain().focus().unsetAllMarks().run()">
clear marks
</button>
<button @click="editor.chain().focus().clearNodes().run()">
clear nodes
</button>
<button @click="editor.chain().focus().setParagraph().run()" :class="{ 'is-active': editor.isActive('paragraph') }">
paragraph
</button>
<button @click="editor.chain().focus().toggleHeading({ level: 1 }).run()" :class="{ 'is-active': editor.isActive('heading', { level: 1 }) }">
h1
</button>
<button @click="editor.chain().focus().toggleHeading({ level: 2 }).run()" :class="{ 'is-active': editor.isActive('heading', { level: 2 }) }">
h2
</button>
<button @click="editor.chain().focus().toggleHeading({ level: 3 }).run()" :class="{ 'is-active': editor.isActive('heading', { level: 3 }) }">
h3
</button>
<button @click="editor.chain().focus().toggleHeading({ level: 4 }).run()" :class="{ 'is-active': editor.isActive('heading', { level: 4 }) }">
h4
</button>
<button @click="editor.chain().focus().toggleHeading({ level: 5 }).run()" :class="{ 'is-active': editor.isActive('heading', { level: 5 }) }">
h5
</button>
<button @click="editor.chain().focus().toggleHeading({ level: 6 }).run()" :class="{ 'is-active': editor.isActive('heading', { level: 6 }) }">
h6
</button>
<button @click="editor.chain().focus().toggleBulletList().run()" :class="{ 'is-active': editor.isActive('bulletList') }">
bullet list
</button>
<button @click="editor.chain().focus().toggleOrderedList().run()" :class="{ 'is-active': editor.isActive('orderedList') }">
ordered list
</button>
<button @click="editor.chain().focus().toggleCodeBlock().run()" :class="{ 'is-active': editor.isActive('codeBlock') }">
code block
</button>
<button @click="editor.chain().focus().toggleBlockquote().run()" :class="{ 'is-active': editor.isActive('blockquote') }">
blockquote
</button>
<button @click="editor.chain().focus().setHorizontalRule().run()">
horizontal rule
</button>
<button @click="editor.chain().focus().setHardBreak().run()">
hard break
</button>
<button @click="editor.chain().focus().undo().run()">
undo
</button>
<button @click="editor.chain().focus().redo().run()">
redo
</button>
<br>
<br>
<button @click="setName">
Set Name
</button>
<button @click="changeName">
Random Name
</button>
<button @click="changeColor">
Random Color
</button>
</div>
<div class="collaboration-status">
{{ users.length }} user{{ users.length === 1 ? '' : 's' }}
</div>
<div class="collaboration-users">
<div
class="collaboration-users__item"
:style="`background-color: ${user.color}`"
v-for="user in users"
:key="user.id"
>
{{ user.name }}
</div>
</div>
<editor-content :editor="editor" />
<div class="collaboration-log">
<div class="collaboration-log__item" v-for="(item, index) in log" :key="index">
[{{ item.timestamp.toLocaleString() }}]
{{ item.status }}
</div>
</div>
</div>
</template>
<script>
import { Editor, EditorContent, defaultExtensions } from '@tiptap/vue-starter-kit'
import Collaboration from '@tiptap/extension-collaboration'
import CollaborationCursor from '@tiptap/extension-collaboration-cursor'
import * as Y from 'yjs'
// import { WebrtcProvider } from 'y-webrtc'
import { WebsocketProvider } from 'y-websocket'
import { IndexeddbPersistence } from 'y-indexeddb'
export default {
components: {
EditorContent,
},
data() {
return {
documentName: 'tiptap-collaboration-example',
name: this.getRandomName(),
color: this.getRandomColor(),
ydoc: null,
provider: null,
type: null,
indexdb: null,
editor: null,
users: [],
log: [],
}
},
mounted() {
this.ydoc = new Y.Doc()
this.type = this.ydoc.getXmlFragment('prosemirror')
this.indexdb = new IndexeddbPersistence(this.documentName, this.ydoc)
// this.provider = new WebrtcProvider(this.documentName, this.ydoc)
// this.provider = new WebsocketProvider('ws://websocket.tiptap.dev', this.documentName, this.ydoc)
this.provider = new WebsocketProvider('ws://127.0.0.1:1234', this.documentName, this.ydoc)
this.provider.on('status', event => {
this.log.unshift({
timestamp: new Date(),
status: event.status,
})
})
this.provider.awareness.on('change', this.updateState)
this.editor = new Editor({
extensions: [
...defaultExtensions(),
Collaboration.configure({
type: this.type,
}),
CollaborationCursor.configure({
provider: this.provider,
name: this.name,
color: this.color,
}),
],
})
this.updateState()
},
methods: {
setName() {
const name = window.prompt('Name')
if (name) {
this.name = name
return this.updateUser()
}
},
changeName() {
this.name = this.getRandomName()
this.updateUser()
},
changeColor() {
this.color = this.getRandomColor()
this.updateUser()
},
updateUser() {
this.editor.chain().focus().user({
name: this.name,
color: this.color,
}).run()
this.updateState()
},
getRandomColor() {
return this.getRandomElement([
'#616161',
'#A975FF',
'#FB5151',
'#fd9170',
'#FFCB6B',
'#68CEF8',
'#80cbc4',
'#9DEF8F',
])
},
getRandomName() {
return this.getRandomElement([
'Lea Thompson', 'Cyndi Lauper', 'Tom Cruise', 'Madonna', 'Jerry Hall', 'Joan Collins', 'Winona Ryder', 'Christina Applegate', 'Alyssa Milano', 'Molly Ringwald', 'Ally Sheedy', 'Debbie Harry', 'Olivia Newton-John', 'Elton John', 'Michael J. Fox', 'Axl Rose', 'Emilio Estevez', 'Ralph Macchio', 'Rob Lowe', 'Jennifer Grey', 'Mickey Rourke', 'John Cusack', 'Matthew Broderick', 'Justine Bateman', 'Lisa Bonet',
])
},
getRandomElement(list) {
return list[Math.floor(Math.random() * list.length)]
},
updateState() {
const { states } = this.provider.awareness
this.users = Array.from(states.entries()).map(state => {
return {
id: state[0],
...state[1].user,
}
})
},
},
beforeDestroy() {
this.editor.destroy()
this.provider.destroy()
},
}
</script>
<style lang="scss">
/* A list of all available users */
.collaboration-users {
margin-top: 0.5rem;
&__item {
display: inline-block;
border-radius: 5px;
padding: 0.25rem 0.5rem;
color: white;
margin-right: 0.5rem;
margin-bottom: 0.5rem;
}
}
/* Some information about the status */
.collaboration-status {
background: #eee;
color: #666;
border-radius: 5px;
padding: 0.5rem 1rem;
margin-top: 1rem;
&::before {
content: ' ';
display: inline-block;
width: 0.5rem;
height: 0.5rem;
background: green;
border-radius: 50%;
margin-right: 0.5rem;
}
}
.collaboration-log {
background: #0D0D0D;
border-radius: 5px;
color: #9DEF8F;
font-family: monospace;
margin-top: 1rem;
padding: 0.25rem 0.5rem;
}
/* Give a remote user a caret */
.collaboration-cursor__caret {
position: relative;
margin-left: -1px;
margin-right: -1px;
border-left: 1px solid black;
border-right: 1px solid black;
word-break: normal;
pointer-events: none;
}
/* Render the username above the caret */
.collaboration-cursor__label {
position: absolute;
top: -1.4em;
left: -1px;
font-size: 13px;
font-style: normal;
font-weight: normal;
line-height: normal;
user-select: none;
color: white;
padding: 0.1rem 0.3rem;
border-radius: 3px;
white-space: nowrap;
}
/* Basic editor styles */
.ProseMirror {
> * + * {
margin-top: 0.75em;
}
ul,
ol {
padding: 0 1rem;
}
code {
background-color: rgba(#616161, 0.1);
color: #616161;
}
pre {
background: #0D0D0D;
color: #FFF;
font-family: 'JetBrainsMono', monospace;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
code {
color: inherit;
background: none;
font-size: 0.8rem;
}
}
img {
max-width: 100%;
height: auto;
}
hr {
margin: 1rem 0;
}
blockquote {
padding-left: 1rem;
border-left: 2px solid rgba(#0D0D0D, 0.1);
}
}
</style>

View File

@@ -1,14 +0,0 @@
/*
import { Server } from '@hocuspocus/server'
import { LevelDB } from '@hocuspocus/leveldb'
const server = Server.configure({
port: 1234,
persistence: new LevelDB({
path: './database',
}),
})
server.listen()
*/

View File

@@ -1,5 +0,0 @@
# Collaborative editing
Websockets
<demo name="Examples/CollaborativeEditingWs" />

View File

@@ -1,14 +1,8 @@
# Collaborative editing # Collaborative editing
:::premium Requires Pro Extensions This example shows how you can use tiptap to let multiple users collaborate in the same document in real-time.
We kindly ask you to sponsor us, before using this example in production. [Read more](/sponsor)
:::
This example shows how you can use tiptap to let different users collaboratively work on the same text in real-time. It connects all clients to a WebSocket server and merges changes to the document with the power of [Y.js](https://github.com/yjs/yjs). If you want to learn more about collaborative text editing, check out [our guide on collaborative editing](/guide/collaborative-editing).
It connects client with WebRTC and merges changes to the document (no matter where they come from) with the awesome library [Y.js](https://github.com/yjs/yjs) by Kevin Jahns. Be aware that in a real-world scenario you would probably add a server, which is also able to merge changes with Y.js.
If you want to learn more about collaborative text editing, [check out our guide on that topic](/guide/collaborative-editing). Anyway, its showtime now:
:::warning Shared Document :::warning Shared Document
Be nice! The content of this editor is shared with other users from the Internet. Be nice! The content of this editor is shared with other users from the Internet.

View File

@@ -20,9 +20,6 @@
- title: Collaborative editing - title: Collaborative editing
link: /examples/collaborative-editing link: /examples/collaborative-editing
pro: true pro: true
- title: Collaborative editing 🚧
link: /examples/collaborative-editing-ws
draft: true
- title: Markdown shortcuts - title: Markdown shortcuts
link: /examples/markdown-shortcuts link: /examples/markdown-shortcuts
# - title: Formatting # - title: Formatting

View File

@@ -46,7 +46,8 @@ const CollaborationCursor = Extension.create({
* Update details of the current user * Update details of the current user
*/ */
user: (attributes: { [key: string]: any }): Command => () => { user: (attributes: { [key: string]: any }): Command => () => {
this.options.provider.awareness.setLocalStateField('user', attributes) this.options.user = attributes
this.options.provider.awareness.setLocalStateField('user', this.options.user)
this.options.onUpdate(awarenessStatesToArray(this.options.provider.awareness.states)) this.options.onUpdate(awarenessStatesToArray(this.options.provider.awareness.states))
return true return true