Working with Documents
- Documents & room-names
- Using a primary storage
- Handling Document changes
- Importing documents
- Importing a document with multiple fields
- Read only mode
Documents & room-names
Most Yjs connection providers (including the y-websocket
provider) use a concept called
room-names. The client will pass a room-name parameter to Hocuspocus which will then be used to
identify the document which is currently being edited. We will call room-name throughout this
documentation document name.
In a real-world app you would probably use the name of your entity and a unique ID. Here is how that could look like for a CMS:
const documentName = 'page.140'
Now you can easily split this to get all desired information separately:
const [entityType, entityID] = documentName.split('.')
console.log(entityType) // prints "page"
console.log(entityID) // prints "140
This is a recommendation, of course you can name your documents however you want!
Using a primary storage
One thing up front:
The following examples are not intended to be your primary storage as serializing to and deserializing from JSON will not store collaboration history steps but only the resulting document. These examples are only meant to store the resulting document for the views of your application or to import it if it doesn't exist in your primary storage.
No worries, we have you covered! We built an extension that makes it easy to use any database: the Database extension. It's just a couple of lines to integrate with your preferred database.
Make sure to always include this extension in your production setups!
Handling Document changes
With the onChange
hook you can listen to changes of the document and handle them. It should return
a Promise. It's payload contains the resulting document as well as the actual update in the Y-Doc
binary format. For more information on the hook and it's payload checkout it'
s API section.
In a real-world application you would probably save the current document to a database, send it via webhook to an API or something else. If you want to send a webhook to an external API we already have you covered: Check out our webhook extension.
It's highly recommended to debounce extensive operations (like API calls) as this hook can be fired up to multiple times a second:
You need to serialize the Y-Doc that Hocuspocus gives you to something you can actually display in your views. Check out the transformers section of the guide for more information.
import {debounce} from 'debounce'
import {Server} from '@hocuspocus/server'
import {TiptapTransformer} from '@hocuspocus/transformer'
import {writeFile} from 'fs'
let debounced
const server = Server.configure({
async onChange(data) {
const save = () => {
// Convert the y-doc to something you can actually use in your views.
// In this example we use the TiptapTransformer to get JSON from the given
// ydoc.
const prosemirrorJSON = TiptapTransformer.fromYdoc(data.document)
// Save your document. In a real-world app this could be a database query
// a webhook or something else
writeFile(
`/path/to/your/documents/${data.documentName}.json`,
prosemirrorJSON
)
// Maybe you want to store the user who changed the document?
// Guess what, you have access to your custom context from the
// onAuthenticate hook here. See authorization & authentication for more
// details
console.log(`Document ${data.documentName} changed by ${data.context.user.name}`)
}
debounced?.clear()
debounced = debounce(() => save, 4000)
debounced()
},
})
server.listen()
Importing documents
If you want to alter the Y-Doc when Hocuspocus creates it, you can use the onLoadDocument
hook
and apply updates directly to the given document. This way you can load your document from a
database, an external API or even the file system if they are not present in
your primary storage. For more information on the hook and it's payload
checkout it's API section.
onLoadDocument
expects a Y-Doc to be returned. Check out
the transformers section of the guide for more information.
import {readFileSync} from 'fs'
import {Server} from '@hocuspocus/server'
import {Doc} from 'yjs'
import {TiptapTransformer} from '@hocuspocus/transformer'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
const server = Server.configure({
async onLoadDocument(data): Doc {
// The Tiptap collaboration extension uses shared types of a single y-doc
// to store different fields in the same document.
// The default field in Tiptap is simply called "default"
const fieldName = 'default'
// Check if the given field already exists in the given y-doc.
// Important: Only import a document if it doesn't exist in the primary data storage!
if (!data.document.isEmpty(fieldName)) {
return
}
// Get the document from somwhere. In a real world application this would
// probably be a database query or an API call
const prosemirrorJSON = JSON.parse(
readFileSync(`/path/to/your/documents/${data.documentName}.json`) || "{}"
)
// Convert the editor format to a y-doc. The TiptapTransformer requires you to pass the list
// of extensions you use in the frontend to create a valid document
return TiptapTransformer.toYdoc(prosemirrorJSON, fieldName, [Document, Paragraph, Text])
},
})
server.listen()
Importing a document with multiple fields
When using multiple fields you can simply merge different documents into the given document:
import {readFileSync} from 'fs'
import {Server} from '@hocuspocus/server'
import {TiptapTransformer} from '@hocuspocus/transformer'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
const generateSampleProsemirrorJson = (text: string) => {
return {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text,
},
],
},
],
}
}
const server = Server.configure({
async onLoadDocument(data) {
// only import things if they are not already set in the primary storage
if (data.document.isEmpty('default')) {
// Get a Y-Doc for the 'default' field …
const defaultField = TiptapTransformer.toYdoc(
generateSampleProsemirrorJson('What is love?'),
'default'
[Document, Paragraph, Text],
)
// … and merge it into the given document
data.document.merge(defaultField)
}
if (data.document.isEmpty('secondary')) {
// Get a Y-Doc for the 'secondary' field …
const secondaryField = TiptapTransformer.toYdoc(
generateSampleProsemirrorJson('Baby don\'t hurt me…'),
'secondary'
[Document, Paragraph, Text],
)
// … and merge it into the given document
data.document.merge(secondaryField)
}
},
})
server.listen()
Read only mode
If you want to restrict the current user only to read the document and it's updates but not apply
updates him- or herself, you can use the connection
property in the onAuthenticate
hooks payload:
import { Server } from '@hocuspocus/server'
const usersWithWriteAccess = [
'jane', 'john', 'christina',
]
const server = Server.configure({
async onAuthenticate(data): Doc {
// Example code to check if the current user has write access by a
// request parameter. In a real world application you would probably
// get the user by a token from your database
if(!usersWithWriteAccess.includes(data.requestParameters.get('user'))) {
// Set the connection to readonly
data.connection.readOnly = true
}
},
})
server.listen()