-
-
Save ManUtopiK/519be72722ec701155a2b3f1e1990453 to your computer and use it in GitHub Desktop.
Tiptap collaboration server handles multiple document using namespaces and rooms
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<template> | |
<div class="editor"> | |
<template v-if="editor && !loading"> | |
<div class="count"> | |
{{ count }} {{ count === 1 ? 'user' : 'users' }} connected to {{ projectPath }}/{{ docName }} | |
</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, | |
}, | |
docName: { | |
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, | |
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) | |
}) | |
this.socket.emit('joinRoom', this.docName) | |
}, | |
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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<template> | |
<div class="container"> | |
<div class="row"> | |
<editor projectPath="project1" docName="doc1" class="col-6"></editor> | |
<editor projectPath="project1" docName="doc6" class="col-6"></editor> | |
<editor projectPath="project3" docName="doc1" 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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import fs from 'fs' | |
import { Step } from 'prosemirror-transform' | |
import schema from './schema.js' | |
import { join } from 'path' | |
import { disconnect } from 'process' | |
// 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 docTrailer = '-db.json' | |
const lockedTrailer = '-db_locked.json' | |
const stepsTrailer = '-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, roomName) { | |
fs.writeFileSync(dbPath + namespaceDir + '/' + roomName + docTrailer, JSON.stringify(data, null, 2)) | |
} | |
function storeSteps({steps, version}, namespaceDir, roomName) { | |
let limitedOldData = [] | |
try { | |
const oldData = JSON.parse(fs.readFileSync(dbPath + namespaceDir + '/' + roomName + stepsTrailer, '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 + '/' + roomName + stepsTrailer, JSON.stringify(newData)) | |
} | |
function storeLocked(locked, namespaceDir, roomName) { | |
fs.writeFileSync(dbPath + namespaceDir + '/' + roomName + lockedTrailer, locked.toString()) | |
} | |
function getDoc(namespaceDir, roomName) { | |
try { | |
return JSON.parse(fs.readFileSync(dbPath + namespaceDir + '/' + roomName + docTrailer, 'utf8')) | |
} catch(e) { | |
return defaultData | |
} | |
} | |
function getLocked(namespaceDir, roomName) { | |
try { | |
return JSON.parse(fs.readFileSync(dbPath + namespaceDir + '/' + roomName + lockedTrailer, 'utf8')) | |
} catch(e) { | |
return false | |
} | |
} | |
function getSteps(version, namespaceDir, roomName) { | |
try { | |
const steps = JSON.parse(fs.readFileSync(dbPath + namespaceDir + '/' + roomName + stepsTrailer, '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('joinRoom', async (room) => { | |
socket.join(room); | |
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, room) | |
if (locked) { | |
// we will do nothing and wait for another client update | |
return | |
} | |
storeLocked(true, namespaceDir, room) | |
const storedData = getDoc(namespaceDir, room) | |
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.in(room).emit('update', { | |
version, | |
steps: getSteps(version, namespaceDir, room), | |
}) | |
storeLocked(false, namespaceDir, room) | |
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, room) | |
storeDoc({ version: newVersion, doc }, namespaceDir, room) | |
await sleep(simulateSlowServerDelay) | |
// send update to everyone (me and others) | |
namespace.in(room).emit('update', { | |
version: newVersion, | |
steps: getSteps(version, namespaceDir, room), | |
}) | |
storeLocked(false, namespaceDir, room) | |
}) | |
// send latest document | |
namespace.in(room).emit('init', getDoc(namespaceDir, room)) | |
// send client count | |
namespace.in(room).emit('getCount', namespace.adapter.rooms[room].length) | |
socket.on('disconnect', () => { | |
if (namespace.adapter.rooms[room]) { | |
namespace.in(room).emit('getCount', namespace.adapter.rooms[room].length) | |
} | |
}) | |
}) | |
}) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment