Skip to content

Instantly share code, notes, and snippets.

@Julien1138
Last active September 3, 2020 07:22
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Julien1138/b480927caf65f65c09ed1629591a9505 to your computer and use it in GitHub Desktop.
Save Julien1138/b480927caf65f65c09ed1629591a9505 to your computer and use it in GitHub Desktop.
Tiptap collaboration server handles multiple document using namespaces
<template>
<div class="editor">
<template v-if="editor && !loading">
<div class="count">
{{ count }} {{ count === 1 ? 'user' : 'users' }} connected to {{ projectPath }}
</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,
},
props: {
projectPath: {
type: String,
required: true,
},
},
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 requests
debounce: 250,
// onSendable is called whenever there are changed we have to send to our server
onSendable: ({ sendable }) => {
this.socket.emit('update', sendable)
},
}),
],
})
},
setCount(count) {
this.count = count
},
},
mounted() {
this.socket = io('http://localhost:6002/' + this.projectPath)
// 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()
this.socket.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>
<template>
<div class="container">
<div class="row">
<editor projectPath="doc1" class="col-6"></editor>
<editor projectPath="group3/projet-z" class="col-6"></editor>
</div>
</div>
</template>
<script>
import Editor from './Editor'
export default {
components: {
Editor,
},
}
</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>
import fs from 'fs'
import { Step } from 'prosemirror-transform'
import schema from './schema.js'
// setup socket server
const app = require('express')()
const http = require('http').Server(app)
const io = require('socket.io')(http)
http.listen(6002)
// options
const simulateSlowServerDelay = 0 // milliseconds
const dbPath = './db'
const docPath = '/db.json'
const lockedPath = '/db_locked.json'
const stepsPath = '/db_steps.json'
const maxStoredSteps = 1000
const defaultData = {
"version": 0,
"doc": { "type": "doc", "content": [{ "type": "paragraph", "content":[{ "type": "text", "text": "Let's start collaborating. Yeah!" }] }] }
}
const sleep = (ms) => (new Promise(resolve => setTimeout(resolve, ms)));
function initProjectDir(namespaceDir) {
if (!fs.existsSync(dbPath + namespaceDir)){
fs.mkdirSync(dbPath + namespaceDir, { recursive: true })
}
}
function storeDoc(data, namespaceDir) {
fs.writeFileSync(dbPath + namespaceDir + docPath, JSON.stringify(data, null, 2))
}
function storeSteps({steps, version}, namespaceDir) {
let limitedOldData = []
try {
const oldData = JSON.parse(fs.readFileSync(dbPath + namespaceDir + stepsPath, 'utf8'))
limitedOldData = oldData.slice(Math.max(oldData.length - maxStoredSteps))
} catch(e) {
}
const newData = [
...limitedOldData,
...steps.map((step, index) => {
return {
step: JSON.parse(JSON.stringify(step)),
version: version + index + 1,
clientID: step.clientID,
}
})
]
fs.writeFileSync(dbPath + namespaceDir + stepsPath, JSON.stringify(newData))
}
function storeLocked(locked, namespaceDir) {
fs.writeFileSync(dbPath + namespaceDir + lockedPath, locked.toString())
}
function getDoc(namespaceDir) {
try {
return JSON.parse(fs.readFileSync(dbPath + namespaceDir + docPath, 'utf8'))
} catch(e) {
return defaultData
}
}
function getLocked(namespaceDir) {
try {
return JSON.parse(fs.readFileSync(dbPath + namespaceDir + lockedPath, 'utf8'))
} catch(e) {
return false
}
}
function getSteps(version, namespaceDir) {
try {
const steps = JSON.parse(fs.readFileSync(dbPath + namespaceDir + stepsPath, 'utf8'))
return steps.filter(step => step.version > version)
} catch(e) {
return []
}
}
const namespaces = io.of(/^\/[a-zA-Z0-9_\/-]+$/)
namespaces.on('connection', socket => {
const namespace = socket.nsp;
const namespaceDir = namespace.name
initProjectDir(namespaceDir)
socket.on('update', async ({ version, clientID, steps }) => {
// we need to check if there is another update processed
// so we store a "locked" state
const locked = getLocked(namespaceDir)
if (locked) {
// we will do nothing and wait for another client update
return
}
storeLocked(true, namespaceDir)
const storedData = getDoc(namespaceDir)
await sleep(simulateSlowServerDelay)
// version mismatch: the stored version is newer
// so we send all steps of this version back to the user
if (storedData.version !== version) {
namespace.emit('update', {
version,
steps: getSteps(version, namespaceDir),
})
storeLocked(false, namespaceDir)
return
}
let doc = schema.nodeFromJSON(storedData.doc)
await sleep(simulateSlowServerDelay)
let newSteps = steps.map(step => {
const newStep = Step.fromJSON(schema, step)
newStep.clientID = clientID
// apply step to document
let result = newStep.apply(doc)
doc = result.doc
return newStep
})
await sleep(simulateSlowServerDelay)
// calculating a new version number is easy
const newVersion = version + newSteps.length
// store data
storeSteps({ version, steps: newSteps }, namespaceDir)
storeDoc({ version: newVersion, doc }, namespaceDir)
await sleep(simulateSlowServerDelay)
// send update to everyone (me and others)
namespace.emit('update', {
version: newVersion,
steps: getSteps(version, namespaceDir),
})
storeLocked(false, namespaceDir)
})
// send latest document
namespace.emit('init', getDoc(namespaceDir))
// send client count
namespace.emit('getCount', io.engine.clientsCount)
socket.on('disconnect', () => {
namespace.emit('getCount', io.engine.clientsCount)
})
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment