npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

editorjs-realtime-collab

v0.5.1

Published

Concurrent real-time plugin for EditorJS

Readme

EditorJS Realtime Collaboration Plugin

A realtime collaboration plugin for Editor.js that synchronizes block changes, cursor selections, and deletion states across multiple clients using your socket implementation of choice.

Live Demo

Features

  • ✅ Realtime block add / update / move / delete

  • ✅ Inline cursor & text selection visualization

  • ✅ Block-level selection + pending deletion state

  • Block locking prevents concurrent edits to the same block

  • ✅ Works with any socket implementation

  • ✅ Type-safe TypeScript API

  • ✅ Throttled updates for performance

Installation

npm i editorjs-realtime-collab

Basic Usage

import EditorJS from '@editorjs/editorjs'
import RealtimeCollabPlugin from 'editorjs-realtime-collab'

const editor = new EditorJS({
    holder: 'editor',
    // other EditorJS config
})

const realtimeCollab = new RealtimeCollabPlugin({
    editor,
    socket: socketInterface,  // Should implement INeededSocketFields interface
})

// Start listening for events
realtimeCollab.listen()

After instantiation, the plugin is idle until you explicitly call listen(). This begins listening for:

  • Editor.js block mutations
  • DOM selection changes
  • Incoming socket messages

Use realtimeCollab.unlisten() to stop listening. Check realtimeCollab.isListening to inspect the current listening state.

Socket Interface Contract

The plugin does not depend on Socket.IO, SignalR, or any specific library.

Your socket only needs to implement this interface:

interface INeededSocketFields {
    send(data: MessageData): void
    on(callback: (data: MessageData) => void): void
    off(): void
    connectionId: string
}

connectionId

  • Must uniquely identify the current user/session

  • Used to ignore self-sent updates

  • Used to associate cursors & selections with users

Configuration Options

new RealtimeCollabPlugin({
    editor,
    socket,
    blockChangeThrottleDelay?,
    blockLockDebounceTime?,
    externalUserIdleTimeout?,
    cursor?,
    overrideStyles?,
    toolsWithDataCheck?,
})

Config Params

| Field | Type | Description | Default | | ------------------------ | ----------------------------------------------- | -------------------------------------------------------- | -------------------- | | editor | EditorJS | The editorJs instance you want to listen to | required* | | socket | INeededSocketFields | The socket instance (or custom method bingings) | required* | | blockChangeThrottleDelay | number | Delay to throttle block changes (ms). | 300 | | blockLockDebounceTime | number | Delay to debounce block unlocking (ms). | 1500 | | externalUserIdleTimeout | number | Remove stale remote users after inactivity (ms). | 60000 | | toolsWithDataCheck | string[] | Tools that need data comparison before locking | ["table"] | | cursor.color | string | Color of remote cursors (set per connectionId) | #0d0c0f | | cursor.selectionColor | string | Color of remote text selections (set per connectionId) | #0d0c0f33 | | overrideStyles.cursorClass | string | Override cursor CSS class | — | | overrideStyles.inlineSelectionClass | string | Override inline selection CSS class | — | | overrideStyles.selectedClass | string | Override selected block class | — | | overrideStyles.pendingDeletionClass | string | Override delete-pending block class | — | | overrideStyles.lockedBlockClass | string | Override locked block CSS class | — |

Listening Control

You can manually control listeners as needed by calling listen() and unlisten():

// Stop listening to editor + socket + DOM
realtimeCollab.unlisten()

// Re-enable all listeners
realtimeCollab.listen()

// Check listening state
if (realtimeCollab.isListening) {
  // ...
}

This is useful when temporarily disabling collaboration, switching documents, or cleaning up in SPA route changes

Cursor Sync & Stale User Cleanup

Use syncExternalCursors() when you need a fresh cursor/selection state from other users (for example after reconnect or remounting the editor). Native onfocus and visibility change events trigger this automatically, but you can call it manually as well:

// Request current cursor/selection state from other connected users
realtimeCollab.syncExternalCursors()

Remote users are treated as stale if no activity is received for externalUserIdleTimeout milliseconds. When stale, their inline cursors, selections, and block locks are removed automatically.

new RealtimeCollabPlugin({
    editor,
    socket,
    externalUserIdleTimeout: 60000, // 60s
})

Block Locking

The plugin automatically locks blocks when a user starts editing them, preventing concurrent modifications by other users. This ensures data consistency and prevents editing conflicts.

How It Works

  • When a user begins editing a block, it gets locked and synced to all other clients
  • Other users see a visual indicator on locked blocks (via CSS)
  • Locked blocks are protected from remote changes until unlocked
  • Blocks automatically unlock after a period of inactivity (debounced)
  • Lock state is tied to connectionId — if a user disconnects, their locks are released

Accessing Lock State

// Get all currently locked blocks by external users
const locks = realtimeCollab.lockedBlocks
// [{blockId: 'abc123', connectionId: 'user-1'}, ...]

// Get the block currently being edited by this user
const myLockedBlock = realtimeCollab.currentLockedBlockId
// 'abc123' or null

Configuration

Control lock timing with blockLockDebounceTime (default: 1500ms):

new RealtimeCollabPlugin({
  editor,
  socket,
  blockLockDebounceTime: 2000, // unlock after 2s of inactivity
})

This prevents locks from being released too quickly during normal typing while ensuring they don't persist indefinitely.

Handling Tools with False Lock Triggers

Some Editor.js tools (like the table tool) emit block change events even when the user isn't actively editing them. This can cause unnecessary block locking.

Use toolsWithDataCheck to specify which tools should have their actual data compared before triggering a lock:

new RealtimeCollabPlugin({
  editor,
  socket,
  toolsWithDataCheck: ['table', 'customTool'], // Only lock if data actually changed
})

How it works:

  • For tools in this list, the plugin compares the block's data and tunes before/after the event
  • Lock is only triggered if the data actually changed
  • Prevents spurious locks from tools that emit events on unrelated editor interactions
  • Default includes ['table'] since it's a known culprit

When to use:

  • You notice blocks getting locked when users aren't editing them
  • A custom tool triggers change events during interactions with other blocks
  • You want tighter control over lock behavior for specific tools

Programmatic UI Updates

Direct Property Modifications

You can programmatically modify the collaboration state by directly updating Plugin.lockedBlocks and Plugin.externalUserSelections. The UI will automatically update to reflect these changes:

// Add or remove locked blocks
realtimeCollab.lockedBlocks = [
  { blockId: 'block-1', connectionId: 'user-2' },
  { blockId: 'block-3', connectionId: 'user-3' }
]

// Add or update external user selections (array of UserInlineSelectionData)
realtimeCollab.externalUserSelections = [
  {
    blockId: 'block-1',
    connectionId: 'user-2',
    elementXPath: '.codex-editor__redactor > div:nth-child(2) > div',
    containerWidth: 800,
    elementNodeIndex: 0,
    anchorOffset: 5,
    focusOffset: 12,
    color: '#0d0c0f',
    selectionColor: '#0d0c0f33'
  }
]

Important: Any changes to these properties will immediately trigger UI updates:

  • lockedBlocks changes will update lock indicators on blocks
  • externalUserSelections changes will update cursor and selection visualizations

This is useful when you need to programmatically sync state, handle reconnections, or update the collaboration state from external events.

Getting Current User's Selection Data

To get the current user's inline selection (cursor and text selection), use the public getSelectionAsData() method:

// Get the current user's selection/cursor data
const selectionData = realtimeCollab.getSelectionAsData()

This returns null if:

  • The document is not focused
  • The document is not visible
  • No selection is made in the editor

To track which block the current user is editing, you can use the currentLockedBlockId property:

// Returns the blockId being edited by this user, or null if not editing
const currentEditingBlockId = realtimeCollab.currentLockedBlockId

Socket Implementation Examples

Socket.IO

import { io } from 'socket.io-client'
const socketInstance = io('wss://example.com/chat')
const connectionId = "user-id"
new RealtimeCollabPlugin({
    editor,
    socket: {
        send: (data) => socketInstance.emit('editorUpdate', data),
        on: (callback) => socketInstance.on('editorUpdate', callback),
        off: (callback) => socketInstance.off('editorUpdate', callback),
        connectionId 
    },
})

Microsoft SignalR

const connection = new signalR.HubConnectionBuilder()
  .withUrl('/chat')
  .build()
const connectionId = "user-id"
connection.start().then(() => {
    new RealtimeCollabPlugin({
        editor,
        socket: {
            send: (data) => connection.send('editorUpdate', data),
            on: (callback) => connection.on('editorUpdate', callback),
            off: (callback) => connection.off('editorUpdate', callback),
            connectionId,
        },
    })
})

Native WebSocket

const socket = new WebSocket('wss://example.com')

socket.addEventListener('open', async (e) => {
    const eventName = "editor-update";

    let listener = null
    
    const off = () => {
        if (listener) {
            socket.removeEventListener('message', listener)
            listener = null
        }
    }
    const on = (callback) => {
        off()
        listener = (e) => {
            const isSameClient = e.currentTarget === socket
            if (isSameClient) return

            const splits = e.data.split(',')
            const receivedEventName = splits[0]
            // Ignore messages that are not for this plugin
            if (eventName !== receivedEventName) return
            const data = JSON.parse(splits[1])
            callback(data)
        }
        socket.addEventListener('message', listener)
    }
    const send = (data) => {
        socket.send([eventName, JSON.stringify(data)])
    }

    const connectionId = "user-id"
    new RealtimeCollabPlugin({
        editor,
        socket: {
            send,
            on,
            off,
            connectionId
        },
    })
})

PieSocket Example

const pieSocket = new PieSocket.default({
    clusterId: 'free.blr2',
    apiKey: 'your-api-key',
})
const channel = await pieSocket.subscribe('channel-name')

const socket = {
    on: (cb: Function) => channel.listen('editorjs-update', (data, meta) => cb(data)),
    send: (data: Object) => channel.publish('editorjs-update', data),
    off: () => {
        channel.unsubscribe()
    },
    connectionId: "user-id"
}

new RealtimeCollabPlugin({
    editor,
    socket,
})

Message Types (Advanced)

Internally, data is synced using strongly typed messages that map directly to Editor.js mutations:

  • Block added / removed / moved / changed

  • Inline selection changes

  • Block selection changes

  • Pending deletion state

  • Block locked / unlocked events

  • User disconnect events

You generally do not need to handle these manually unless:

  • You are proxying messages through a server

  • You want to log or transform events


Styling

The plugin injects default styles for:

  • Remote cursors

  • Inline selections

  • Selected blocks

  • Pending deletions

  • Locked blocks

You can override any of them via overrideStyles or your own CSS.

Note: Cursor and selection colors are set individually per connection. When configuring colors dynamically, use a mapping like colorMap[connectionId] to assign unique colors to each user.


Gotchas & Notes

  • ⚠️ connectionId must be stable for a user session and unique across users to prevent update conflicts

  • ✅ Editor content stays consistent even with rapid concurrent edits

  • ✅ Self-emitted events are automatically ignored

  • ✅ Use toolsWithDataCheck to prevent false locks from tools that emit frequent change events (like tables) `

Architecture Overview

High-Level Architecture

graph TD
  UserA[User A<br/>Editor.js] -->|Block & Selection Events| PluginA[RealtimeCollabPlugin]
  PluginA -->|"send(MessageData)"| SocketA[Socket Adapter]
  SocketA -->|broadcast| Server[Relay Server]

  Server -->|MessageData| SocketB[Socket Adapter]
  Server -->|MessageData| SocketC[Socket Adapter]

  SocketB -->|"on(MessageData)"| PluginB[RealtimeCollabPlugin]
  SocketC -->|"on(MessageData)"| PluginC[RealtimeCollabPlugin]

  PluginB -->|Apply Mutations| UserB[User B<br/>Editor.js]
  PluginC -->|Apply Mutations| UserC[User C<br/>Editor.js]

Selection & Cursor Sync

sequenceDiagram
  participant User
  participant EditorJS
  participant Plugin
  participant DOM
  participant Socket

  User->>DOM: Select text / move cursor
  DOM->>Plugin: SelectionChange
  Plugin->>Plugin: Calculate DOM rects
  Plugin->>Socket: send(inline-selection-change)

  Socket->>Plugin: receive(selection-change)
  Plugin->>DOM: Render fake cursor & selection