snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-15 21:11:02 +05:00
commit 083a46e93b
2115 changed files with 245958 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
# @tiptap/extension-collaboration-caret
[![Version](https://img.shields.io/npm/v/@tiptap/extension-collaboration-caret.svg?label=version)](https://www.npmjs.com/package/@tiptap/extension-collaboration-caret)
[![Downloads](https://img.shields.io/npm/dm/@tiptap/extension-collaboration-caret.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@tiptap/extension-collaboration-caret.svg)](https://www.npmjs.com/package/@tiptap/extension-collaboration-caret)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis)
## Introduction
Tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) – a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as _New York Times_, _The Guardian_ or _Atlassian_.
## Official Documentation
Documentation can be found on the [Tiptap website](https://tiptap.dev).
## License
Tiptap is open sourced software licensed under the [MIT license](https://github.com/ueberdosis/tiptap/blob/main/LICENSE.md).

View File

@@ -0,0 +1,60 @@
{
"name": "@tiptap/extension-collaboration-caret",
"version": "3.30.3",
"description": "collaboration caret extension for tiptap",
"keywords": [
"tiptap",
"tiptap extension"
],
"homepage": "https://tiptap.dev",
"bugs": {
"url": "https://github.com/ueberdosis/tiptap/issues"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/ueberdosis/tiptap",
"directory": "packages/extension-collaboration-caret"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"files": [
"src",
"dist"
],
"type": "module",
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": {
"import": "./dist/index.d.ts",
"require": "./dist/index.d.cts"
},
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"scripts": {
"build": "vp pack"
},
"devDependencies": {
"@tiptap/core": "workspace:^",
"@tiptap/extension-collaboration": "workspace:^",
"@tiptap/extension-document": "workspace:^",
"@tiptap/extension-paragraph": "workspace:^",
"@tiptap/extension-table": "workspace:^",
"@tiptap/extension-text": "workspace:^",
"@tiptap/pm": "workspace:^",
"@tiptap/y-tiptap": "^3.0.7",
"yjs": "^13.6.23"
},
"peerDependencies": {
"@tiptap/core": "workspace:*",
"@tiptap/pm": "workspace:*",
"@tiptap/y-tiptap": "^3.0.7"
}
}

View File

@@ -0,0 +1,36 @@
import { describe, expect, test } from 'vite-plus/test'
import { CollaborationCaret } from './collaboration-caret.js'
const maliciousColor =
'red; position:fixed; top:0; left:0; width:100vw; height:100vh; z-index:2147483647; background:black'
describe('default collaboration caret renderers', () => {
test('does not apply extra declarations from a user color', () => {
const cursor = CollaborationCaret.options.render({ name: 'Bob', color: maliciousColor })
const label = cursor.querySelector<HTMLElement>('.collaboration-carets__label')
expect(cursor.style.borderColor).toBe('transparent')
expect(cursor.style.position).toBe('')
expect(cursor.style.width).toBe('')
expect(label?.style.backgroundColor).toBe('transparent')
})
test('does not add extra declarations to a selection', () => {
const attributes = CollaborationCaret.options.selectionRender({
name: 'Bob',
color: maliciousColor,
})
expect(attributes.style).toBeUndefined()
})
test('applies a six-digit hex color to a selection', () => {
const attributes = CollaborationCaret.options.selectionRender({
name: 'Bob',
color: '#ff0000',
})
expect(attributes.style).toBe('background-color: #ff000070')
})
})

View File

@@ -0,0 +1,225 @@
import { Extension } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import type { DecorationAttrs } from '@tiptap/pm/view'
import { defaultSelectionBuilder, yCursorPlugin } from '@tiptap/y-tiptap'
import { isValidColor } from './lib/isValidColor.js'
type CollaborationCaretStorage = {
users: { clientId: number; [key: string]: any }[]
}
export interface CollaborationCaretOptions {
/**
* The Hocuspocus provider instance. This can also be a TiptapCloudProvider instance.
* @type {HocuspocusProvider | TiptapCloudProvider}
* @example new HocuspocusProvider()
*/
provider: any
/**
* The user details object – feel free to add properties to this object as needed
* @example { name: 'John Doe', color: '#305500' }
*/
user: Record<string, any>
/**
* A function that returns a DOM element for the cursor.
* @param user The user details object
* @example
* render: user => {
* const cursor = document.createElement('span')
* cursor.classList.add('collaboration-carets__caret')
* cursor.style.borderColor = user.color
*
* const label = document.createElement('div')
* label.classList.add('collaboration-carets__label')
* label.style.backgroundColor = user.color
* label.insertBefore(document.createTextNode(user.name), null)
*
* cursor.insertBefore(label, null)
* return cursor
* }
*/
render(user: Record<string, any>): HTMLElement
/**
* A function that returns a ProseMirror DecorationAttrs object for the selection.
* @param user The user details object
* @example
* selectionRender: user => {
* return {
* nodeName: 'span',
* class: 'collaboration-carets__selection',
* 'data-user': user.name,
* }
*/
selectionRender(user: Record<string, any>): DecorationAttrs
/**
* @deprecated The "onUpdate" option is deprecated. Please use `editor.storage.collaborationCaret.users` instead. Read more: https://tiptap.dev/api/extensions/collaboration-caret
*/
onUpdate: (users: { clientId: number; [key: string]: any }[]) => null
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
collaborationCaret: {
/**
* Update details of the current user
* @example editor.commands.updateUser({ name: 'John Doe', color: '#305500' })
*/
updateUser: (attributes: Record<string, any>) => ReturnType
/**
* Update details of the current user
*
* @deprecated The "user" command is deprecated. Please use "updateUser" instead. Read more: https://tiptap.dev/api/extensions/collaboration-caret
*/
user: (attributes: Record<string, any>) => ReturnType
}
}
interface Storage {
collaborationCaret: CollaborationCaretStorage
}
}
const awarenessStatesToArray = (states: Map<number, Record<string, any> | null | undefined>) => {
return Array.from(states.entries()).map(([key, value]) => {
if (value && value.user) {
return {
clientId: key,
...value.user,
}
}
return {
clientId: key,
}
})
}
const defaultOnUpdate = () => null
const sanitizeUserColor = (user: Record<string, any>) => ({
...user,
color: isValidColor(user.color) ? user.color : 'transparent',
})
/**
* This extension allows you to add collaboration carets to your editor.
* @see https://tiptap.dev/api/extensions/collaboration-caret
*/
export const CollaborationCaret = Extension.create<
CollaborationCaretOptions,
CollaborationCaretStorage
>({
name: 'collaborationCaret',
priority: 999,
addOptions() {
return {
provider: null,
user: {
name: null,
color: null,
},
render: user => {
const cursor = document.createElement('span')
const { color } = sanitizeUserColor(user)
cursor.classList.add('collaboration-carets__caret')
cursor.style.borderColor = color
const label = document.createElement('div')
label.classList.add('collaboration-carets__label')
label.style.backgroundColor = color
label.insertBefore(document.createTextNode(user.name), null)
cursor.insertBefore(label, null)
return cursor
},
selectionRender: user => {
if (!isValidColor(user.color)) {
return {}
}
return defaultSelectionBuilder(user)
},
onUpdate: defaultOnUpdate,
}
},
onCreate() {
if (this.options.onUpdate !== defaultOnUpdate) {
console.warn(
'[tiptap warn]: DEPRECATED: The "onUpdate" option is deprecated. Please use `editor.storage.collaborationCaret.users` instead. Read more: https://tiptap.dev/api/extensions/collaboration-caret',
)
}
if (!this.options.provider) {
throw new Error('The "provider" option is required for the CollaborationCaret extension')
}
},
addStorage() {
return {
users: [],
}
},
addCommands() {
return {
updateUser: attributes => () => {
this.options.provider.awareness.setLocalStateField('user', attributes)
return true
},
user:
attributes =>
({ editor }) => {
console.warn(
'[tiptap warn]: DEPRECATED: The "user" command is deprecated. Please use "updateUser" instead. Read more: https://tiptap.dev/api/extensions/collaboration-caret',
)
return editor.commands.updateUser(attributes)
},
}
},
addProseMirrorPlugins() {
const { provider } = this.options
const storage = this.storage
const user = this.options.user
// Owns the awareness 'update' subscription so it's torn down when the editor
// is destroyed. Without this, the listener would keep the editor reachable
// from a shared provider's awareness emitter and leak memory across editors.
const awarenessListenerPlugin = new Plugin({
key: new PluginKey('collaborationCaretAwarenessListener'),
view: () => {
const onAwarenessUpdate = () => {
storage.users = awarenessStatesToArray(provider.awareness.states)
}
provider.awareness.setLocalStateField('user', user)
storage.users = awarenessStatesToArray(provider.awareness.states)
provider.awareness.on('update', onAwarenessUpdate)
return {
destroy: () => {
provider.awareness.off('update', onAwarenessUpdate)
storage.users = []
},
}
},
})
return [
awarenessListenerPlugin,
yCursorPlugin(provider.awareness, {
cursorBuilder: currentUser => this.options.render(sanitizeUserColor(currentUser)),
selectionBuilder: currentUser =>
this.options.selectionRender(sanitizeUserColor(currentUser)),
}),
]
},
})

View File

@@ -0,0 +1,5 @@
import { CollaborationCaret } from './collaboration-caret.js'
export * from './collaboration-caret.js'
export default CollaborationCaret

View File

@@ -0,0 +1,29 @@
import { describe, expect, test } from 'vite-plus/test'
import { isValidColor } from './isValidColor.js'
const maliciousColor =
'red; position:fixed; top:0; left:0; width:100vw; height:100vh; z-index:2147483647; background:black'
describe('isValidColor', () => {
// we only expect 6-digit hex values because thats
// what y-tiptap is exposing to the client
test.each(['#ffffff', '#1a2b3c', '#ABCDEF'])('returns true for a six-digit hex color', color => {
expect(isValidColor(color)).toBe(true)
})
test.each([
null,
undefined,
'#fff',
'#ffff',
'#ffffffff',
'red',
'rgb(255, 0, 0)',
maliciousColor,
'red; background-image: url(https://example.com)',
'red } body { display: none',
])('returns false for an unsupported color', color => {
expect(isValidColor(color)).toBe(false)
})
})

View File

@@ -0,0 +1,3 @@
export function isValidColor(color: unknown): color is string {
return typeof color === 'string' && /^#[0-9a-fA-F]{6}$/.test(color)
}

View File

@@ -0,0 +1,295 @@
import { Editor } from '@tiptap/core'
import Collaboration from '@tiptap/extension-collaboration'
import CollaborationCaret from '@tiptap/extension-collaboration-caret'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import { Table, TableCell, TableHeader, TableRow } from '@tiptap/extension-table'
import Text from '@tiptap/extension-text'
import { describe, expect, it } from 'vite-plus/test'
import * as Y from 'yjs'
/**
* Tests for CollaborationCaret extension, especially edge cases
* with initial HTML content and complex structures like tables.
*/
describe('extension-collaboration-caret', () => {
const editorElClass = 'tiptap'
const createEditorEl = () => {
const editorEl = document.createElement('div')
editorEl.classList.add(editorElClass)
document.body.appendChild(editorEl)
return editorEl
}
const getEditorEl = () => document.querySelector(`.${editorElClass}`)
/**
* Test that the editor can be initialized with HTML content containing tables
* when using Collaboration and CollaborationCaret extensions.
*
* This test reproduces the issue from GitHub #6979 where the editor crashes
* with "Cannot read properties of undefined (reading 'doc')" when initialized
* with HTML content containing tables.
*/
it('should not crash when initialized with HTML content containing tables', () => {
const ydoc = new Y.Doc()
// Create a mock provider with minimal awareness API
const mockProvider = {
awareness: {
states: new Map(),
setLocalStateField: () => {},
on: () => {},
off: () => {},
getStates: () => new Map(),
},
}
const editorEl = createEditorEl()
// This should not throw an error
expect(() => {
const editor = new Editor({
element: editorEl,
extensions: [
Document,
Paragraph,
Text,
Table,
TableRow,
TableHeader,
TableCell,
Collaboration.configure({
document: ydoc,
}),
CollaborationCaret.configure({
provider: mockProvider,
user: {
name: 'Test User',
color: '#ff0000',
},
}),
],
content: `
<table>
<tr>
<th>Header 1</th>
<th>Header 2</th>
</tr>
<tr>
<td>Cell 1</td>
<td>Cell 2</td>
</tr>
</table>
`,
})
editor.destroy()
}).not.toThrow()
getEditorEl()?.remove()
})
/**
* Test that the editor can be initialized with simple HTML content
* when using Collaboration and CollaborationCaret extensions.
*/
it('should not crash when initialized with simple HTML content', () => {
const ydoc = new Y.Doc()
const mockProvider = {
awareness: {
states: new Map(),
setLocalStateField: () => {},
on: () => {},
off: () => {},
getStates: () => new Map(),
},
}
const editorEl = createEditorEl()
expect(() => {
const editor = new Editor({
element: editorEl,
extensions: [
Document,
Paragraph,
Text,
Collaboration.configure({
document: ydoc,
}),
CollaborationCaret.configure({
provider: mockProvider,
user: {
name: 'Test User',
color: '#ff0000',
},
}),
],
content: '<p>Hello world</p>',
})
editor.destroy()
}).not.toThrow()
getEditorEl()?.remove()
})
/**
* Regression test: when an editor is destroyed but the provider/awareness
* lives on (e.g. multiple editors sharing one provider), the awareness
* 'update' listener registered by CollaborationCaret must be removed.
* Otherwise the closure keeps the destroyed editor reachable and leaks memory.
*/
it('should remove its awareness listener when the editor is destroyed', () => {
const ydoc = new Y.Doc()
const updateListeners = new Set<(...args: any[]) => void>()
const mockAwareness = {
states: new Map(),
setLocalStateField: () => {},
on: (event: string, fn: (...args: any[]) => void) => {
if (event === 'update') updateListeners.add(fn)
},
off: (event: string, fn: (...args: any[]) => void) => {
if (event === 'update') updateListeners.delete(fn)
},
getStates: () => new Map(),
}
const mockProvider = { awareness: mockAwareness }
const editorEl = createEditorEl()
const editor = new Editor({
element: editorEl,
extensions: [
Document,
Paragraph,
Text,
Collaboration.configure({ document: ydoc }),
CollaborationCaret.configure({
provider: mockProvider,
user: { name: 'Test User', color: '#ff0000' },
}),
],
})
expect(updateListeners.size).toBe(1)
editor.destroy()
expect(updateListeners.size).toBe(0)
getEditorEl()?.remove()
})
/**
* Test that the editor can be initialized without any content
* when using Collaboration and CollaborationCaret extensions.
*/
it('should work correctly when initialized without content', () => {
const ydoc = new Y.Doc()
const mockProvider = {
awareness: {
states: new Map(),
setLocalStateField: () => {},
on: () => {},
off: () => {},
getStates: () => new Map(),
},
}
const editorEl = createEditorEl()
expect(() => {
const editor = new Editor({
element: editorEl,
extensions: [
Document,
Paragraph,
Text,
Collaboration.configure({
document: ydoc,
}),
CollaborationCaret.configure({
provider: mockProvider,
user: {
name: 'Test User',
color: '#ff0000',
},
}),
],
})
editor.destroy()
}).not.toThrow()
getEditorEl()?.remove()
})
it('should handle null or undefined awareness state values without crashing', () => {
const ydoc = new Y.Doc()
const states = new Map<number, Record<string, any> | null>()
states.set(1, { user: { name: 'Alice', color: '#ff0000' } })
states.set(2, null as any)
states.set(3, undefined as any)
states.set(4, { user: { name: 'Bob', color: '#00ff00' } })
const mockProvider = {
awareness: {
states,
setLocalStateField: () => {},
on: () => {},
off: () => {},
getStates: () => states,
},
}
const editorEl = createEditorEl()
const editor = new Editor({
element: editorEl,
extensions: [
Document,
Paragraph,
Text,
Collaboration.configure({
document: ydoc,
}),
CollaborationCaret.configure({
provider: mockProvider,
user: {
name: 'Test User',
color: '#0000ff',
},
}),
],
})
const users = editor.storage.collaborationCaret.users
expect(users).toEqual(
expect.arrayContaining([
expect.objectContaining({ clientId: 1, name: 'Alice', color: '#ff0000' }),
expect.objectContaining({ clientId: 4, name: 'Bob', color: '#00ff00' }),
]),
)
const nullUser = users.find((u: any) => u.clientId === 2)
expect(nullUser).toEqual({ clientId: 2 })
const undefinedUser = users.find((u: any) => u.clientId === 3)
expect(undefinedUser).toEqual({ clientId: 3 })
editor.destroy()
getEditorEl()?.remove()
})
})

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite-plus'
import { basePackConfig } from '../../pack.config.mjs'
export default defineConfig({
pack: {
entry: ['src/index.ts'],
...basePackConfig(),
},
})