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.4.3

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: socketInstance,
})

After instantiation, the plugin is idle until you explicitly start it. Call realtimeCollab.listen() to begin listening for:

  • Editor.js block mutations

  • DOM selection changes

  • Incoming socket messages

Use realtimeCollab.unlisten() to stop listening, and check realtimeCollab.isListening to inspect the current 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 NeededSocketFields<SocketEventName extends string> {
  send(event: SocketEventName, data: MessageData): void
  on(event: SocketEventName, callback: (data: MessageData) => void): void
  off(event: SocketEventName): 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,
  socketMethodName?,
  blockChangeThrottleDelay?,
  blockLockDebounceTime?,
  cursor?,
  overrideStyles?,
})

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* | | socketMethodName | string | The event name to use when communicating between sockets | editorjs-update | | blockChangeThrottleDelay | number | Delay to throttle block changes (ms). | 300 | | blockLockDebounceTime | number | Delay to debounce block unlocking (ms). | 1500 | | 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

By default, the plugin starts listening immediately.

You can manually control listeners if needed:

// 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

  • Cleaning up in SPA route changes

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

Examples

Socket.IO

import { io } from 'socket.io-client'
const socketInstance = io('wss://example.com/chat')
const connectionId = "user-id"
new GroupCollab({
    editor,
    socket: { 
        ...socketInstance, 
        connectionId 
    },
})

Microsoft SignalR

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

Native WebSocket (Custom Binding)

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

socket.addEventListener('open', async (e) => {
    const on = (eventName, callback) => {
        socket.addEventListener('message', (e) => {
            const isSameClient = e.currentTarget === socket
            if (isSameClient) return

            const splits = e.data.split(',')
            const receivedEventName = splits[0]
            if (eventName !== receivedEventName) return
            const data = JSON.parse(splits[1])
            callback(data)
        })
    }
    const send = (eventName, data) => {
        socket.send([eventName, JSON.stringify(data)])
    }
    const off = (eventName) => {
        /* handle unsubscribing logic */
    }
    const connectionId = "user-id"
    const groupCollab = 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: (name: string, cb: Function) => channel.listen(name, (data, meta) => cb(data)),
    send: (name: string, data: Object) => channel.publish(name, data),
    off: () => {
        /* unsubscribing logic */
    },
    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

  • ⚠️ Clients must all use the same socketMethodName

  • ✅ Editor content stays consistent even with rapid concurrent edits

  • ✅ Self-emitted events are automatically ignored `

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