@rgby/collab-core 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,188 @@
1
+ import { Extension } from '@tiptap/core'
2
+ import type { DecorationAttrs } from '@tiptap/pm/view'
3
+ import { defaultSelectionBuilder, yCursorPlugin } from '@tiptap/y-tiptap'
4
+
5
+ type CollaborationCaretStorage = {
6
+ users: { clientId: number;[key: string]: any }[]
7
+ }
8
+
9
+ export interface CollaborationCaretOptions {
10
+ /**
11
+ * The Hocuspocus provider instance. This can also be a TiptapCloudProvider instance.
12
+ * @type {HocuspocusProvider | TiptapCloudProvider}
13
+ * @example new HocuspocusProvider()
14
+ */
15
+ provider: any
16
+
17
+ /**
18
+ * The user details object – feel free to add properties to this object as needed
19
+ * @example { name: 'John Doe', color: '#305500' }
20
+ */
21
+ user: Record<string, any>
22
+
23
+ /**
24
+ * A function that returns a DOM element for the cursor.
25
+ * @param user The user details object
26
+ * @example
27
+ * render: user => {
28
+ * const cursor = document.createElement('span')
29
+ * cursor.classList.add('collaboration-carets__caret')
30
+ * cursor.setAttribute('style', `border-color: ${user.color}`)
31
+ *
32
+ * const label = document.createElement('div')
33
+ * label.classList.add('collaboration-carets__label')
34
+ * label.setAttribute('style', `background-color: ${user.color}`)
35
+ * label.insertBefore(document.createTextNode(user.name), null)
36
+ *
37
+ * cursor.insertBefore(label, null)
38
+ * return cursor
39
+ * }
40
+ */
41
+ render(user: Record<string, any>): HTMLElement
42
+
43
+ /**
44
+ * A function that returns a ProseMirror DecorationAttrs object for the selection.
45
+ * @param user The user details object
46
+ * @example
47
+ * selectionRender: user => {
48
+ * return {
49
+ * nodeName: 'span',
50
+ * class: 'collaboration-carets__selection',
51
+ * style: `background-color: ${user.color}`,
52
+ * 'data-user': user.name,
53
+ * }
54
+ */
55
+ selectionRender(user: Record<string, any>): DecorationAttrs
56
+
57
+ /**
58
+ * @deprecated The "onUpdate" option is deprecated. Please use `editor.storage.collaborationCaret.users` instead. Read more: https://tiptap.dev/api/extensions/collaboration-caret
59
+ */
60
+ onUpdate: (users: { clientId: number;[key: string]: any }[]) => null
61
+ }
62
+
63
+ declare module '@tiptap/core' {
64
+ interface Commands<ReturnType> {
65
+ collaborationCaret: {
66
+ /**
67
+ * Update details of the current user
68
+ * @example editor.commands.updateUser({ name: 'John Doe', color: '#305500' })
69
+ */
70
+ updateUser: (attributes: Record<string, any>) => ReturnType
71
+ /**
72
+ * Update details of the current user
73
+ *
74
+ * @deprecated The "user" command is deprecated. Please use "updateUser" instead. Read more: https://tiptap.dev/api/extensions/collaboration-caret
75
+ */
76
+ user: (attributes: Record<string, any>) => ReturnType
77
+ }
78
+ }
79
+
80
+ interface Storage {
81
+ collaborationCaret: CollaborationCaretStorage
82
+ }
83
+ }
84
+
85
+ const awarenessStatesToArray = (states: Map<number, Record<string, any>>) => {
86
+ return Array.from(states.entries()).map(([key, value]) => {
87
+ return {
88
+ clientId: key,
89
+ ...value.user,
90
+ }
91
+ })
92
+ }
93
+
94
+ const defaultOnUpdate = () => null
95
+
96
+ /**
97
+ * This extension allows you to add collaboration carets to your editor.
98
+ * @see https://tiptap.dev/api/extensions/collaboration-caret
99
+ */
100
+ export const CollaborationCursor = Extension.create<CollaborationCaretOptions, CollaborationCaretStorage>({
101
+ name: 'collaborationCursor', // Kept generic name to match expectation
102
+
103
+ priority: 999,
104
+
105
+ addOptions() {
106
+ return {
107
+ provider: null,
108
+ user: {
109
+ name: null,
110
+ color: null,
111
+ },
112
+ render: user => {
113
+ const cursor = document.createElement('span')
114
+
115
+ cursor.classList.add('collaboration-cursor__caret')
116
+ cursor.setAttribute('style', `border-color: ${user.color}`)
117
+
118
+ const label = document.createElement('div')
119
+
120
+ label.classList.add('collaboration-cursor__label')
121
+ label.setAttribute('style', `background-color: ${user.color}`)
122
+ label.insertBefore(document.createTextNode(user.name), null)
123
+ cursor.insertBefore(label, null)
124
+
125
+ return cursor
126
+ },
127
+ selectionRender: defaultSelectionBuilder,
128
+ onUpdate: defaultOnUpdate,
129
+ }
130
+ },
131
+
132
+ onCreate() {
133
+ if (this.options.onUpdate !== defaultOnUpdate) {
134
+ console.warn(
135
+ '[tiptap warn]: DEPRECATED: The "onUpdate" option is deprecated. Please use `editor.storage.collaborationCaret.users` instead. Read more: https://tiptap.dev/api/extensions/collaboration-caret',
136
+ )
137
+ }
138
+ if (!this.options.provider) {
139
+ throw new Error('The "provider" option is required for the CollaborationCaret extension')
140
+ }
141
+ },
142
+
143
+ addStorage() {
144
+ return {
145
+ users: [],
146
+ }
147
+ },
148
+
149
+ addCommands() {
150
+ return {
151
+ updateUser: (attributes: Record<string, any>) => () => {
152
+ this.options.provider.awareness.setLocalStateField('user', attributes)
153
+ return true
154
+ },
155
+ user:
156
+ (attributes: Record<string, any>) =>
157
+ ({ editor }) => {
158
+ console.warn(
159
+ '[tiptap warn]: DEPRECATED: The "user" command is deprecated. Please use "updateUser" instead. Read more: https://tiptap.dev/api/extensions/collaboration-caret',
160
+ )
161
+
162
+ return editor.commands.updateUser(attributes)
163
+ },
164
+ }
165
+ },
166
+
167
+ addProseMirrorPlugins() {
168
+ return [
169
+ yCursorPlugin(
170
+ (() => {
171
+ this.options.provider.awareness.setLocalStateField('user', this.options.user)
172
+
173
+ this.storage.users = awarenessStatesToArray(this.options.provider.awareness.states)
174
+
175
+ this.options.provider.awareness.on('update', () => {
176
+ this.storage.users = awarenessStatesToArray(this.options.provider.awareness.states)
177
+ })
178
+
179
+ return this.options.provider.awareness
180
+ })(),
181
+ {
182
+ cursorBuilder: this.options.render,
183
+ selectionBuilder: this.options.selectionRender,
184
+ },
185
+ ),
186
+ ]
187
+ },
188
+ })
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export * from './collab-provider.js'
2
+
3
+ export * from './upload-queue.js'
4
+ export * from './services/sw-manager.js'
5
+ export { Collaboration } from '@tiptap/extension-collaboration'
6
+ export { CollaborationCursor } from './extensions/collaboration-cursor.js'
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Service Worker Manager
3
+ * Handles SW registration, updates, and cache management
4
+ */
5
+
6
+ export interface ServiceWorkerManagerOptions {
7
+ scriptUrl?: string;
8
+ scope?: string;
9
+ onUpdate?: (registration: ServiceWorkerRegistration) => void;
10
+ onReady?: (registration: ServiceWorkerRegistration) => void;
11
+ onError?: (error: Error) => void;
12
+ }
13
+
14
+ export class ServiceWorkerManager {
15
+ private registration: ServiceWorkerRegistration | null = null;
16
+ private options: ServiceWorkerManagerOptions;
17
+ private updateCheckInterval: ReturnType<typeof setInterval> | null = null;
18
+
19
+ constructor(options: ServiceWorkerManagerOptions = {}) {
20
+ this.options = {
21
+ scriptUrl: options.scriptUrl || '/sw.js',
22
+ scope: options.scope || '/',
23
+ ...options
24
+ };
25
+ }
26
+
27
+ /**
28
+ * Register the Service Worker
29
+ */
30
+ async register(): Promise<ServiceWorkerRegistration | null> {
31
+ // Check if Service Worker is supported
32
+ if (!('serviceWorker' in navigator)) {
33
+ console.warn('[SW Manager] Service Workers not supported in this browser');
34
+ return null;
35
+ }
36
+
37
+ // Check if we're in a secure context (HTTPS or localhost)
38
+ if (!window.isSecureContext) {
39
+ console.warn('[SW Manager] Service Workers require a secure context (HTTPS)');
40
+ return null;
41
+ }
42
+
43
+ try {
44
+ console.log('[SW Manager] Registering Service Worker...');
45
+ const registration = await navigator.serviceWorker.register(
46
+ this.options.scriptUrl!,
47
+ { scope: this.options.scope }
48
+ );
49
+
50
+ this.registration = registration;
51
+
52
+ // Handle updates
53
+ registration.addEventListener('updatefound', () => {
54
+ const newWorker = registration.installing;
55
+ if (!newWorker) return;
56
+
57
+ newWorker.addEventListener('statechange', () => {
58
+ if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
59
+ console.log('[SW Manager] New version available');
60
+ if (this.options.onUpdate) {
61
+ this.options.onUpdate(registration);
62
+ }
63
+ }
64
+ });
65
+ });
66
+
67
+ // Check if SW is already active
68
+ if (registration.active) {
69
+ console.log('[SW Manager] Service Worker active');
70
+ if (this.options.onReady) {
71
+ this.options.onReady(registration);
72
+ }
73
+ }
74
+
75
+ // Periodically check for updates (every 1 hour)
76
+ this.updateCheckInterval = setInterval(() => {
77
+ registration.update().catch((error) => {
78
+ console.error('[SW Manager] Update check failed:', error);
79
+ });
80
+ }, 60 * 60 * 1000);
81
+
82
+ console.log('[SW Manager] Service Worker registered successfully');
83
+ return registration;
84
+ } catch (error) {
85
+ console.error('[SW Manager] Registration failed:', error);
86
+ if (this.options.onError) {
87
+ this.options.onError(error as Error);
88
+ }
89
+ return null;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Unregister the Service Worker
95
+ */
96
+ async unregister(): Promise<boolean> {
97
+ if (!this.registration) {
98
+ return false;
99
+ }
100
+
101
+ try {
102
+ const success = await this.registration.unregister();
103
+ if (success) {
104
+ console.log('[SW Manager] Service Worker unregistered');
105
+ this.registration = null;
106
+ if (this.updateCheckInterval) {
107
+ clearInterval(this.updateCheckInterval);
108
+ this.updateCheckInterval = null;
109
+ }
110
+ }
111
+ return success;
112
+ } catch (error) {
113
+ console.error('[SW Manager] Unregistration failed:', error);
114
+ return false;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Update the Service Worker
120
+ */
121
+ async update(): Promise<void> {
122
+ if (!this.registration) {
123
+ throw new Error('No Service Worker registered');
124
+ }
125
+
126
+ try {
127
+ await this.registration.update();
128
+ console.log('[SW Manager] Update check complete');
129
+ } catch (error) {
130
+ console.error('[SW Manager] Update failed:', error);
131
+ throw error;
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Skip waiting and activate new Service Worker immediately
137
+ */
138
+ async skipWaiting(): Promise<void> {
139
+ if (!this.registration || !this.registration.waiting) {
140
+ return;
141
+ }
142
+
143
+ // Send message to waiting SW to skip waiting
144
+ this.registration.waiting.postMessage({ type: 'SKIP_WAITING' });
145
+
146
+ // Wait for the new SW to take control
147
+ return new Promise<void>((resolve) => {
148
+ navigator.serviceWorker.addEventListener('controllerchange', () => {
149
+ console.log('[SW Manager] New Service Worker activated');
150
+ resolve();
151
+ });
152
+ });
153
+ }
154
+
155
+ /**
156
+ * Clear all caches
157
+ */
158
+ async clearCache(): Promise<boolean> {
159
+ if (!this.registration || !this.registration.active) {
160
+ return false;
161
+ }
162
+
163
+ return new Promise((resolve) => {
164
+ const messageChannel = new MessageChannel();
165
+ messageChannel.port1.onmessage = (event) => {
166
+ resolve(event.data.success || false);
167
+ };
168
+
169
+ this.registration!.active!.postMessage(
170
+ { type: 'CLEAR_CACHE' },
171
+ [messageChannel.port2]
172
+ );
173
+ });
174
+ }
175
+
176
+ /**
177
+ * Get current cache size
178
+ */
179
+ async getCacheSize(): Promise<number> {
180
+ if (!this.registration || !this.registration.active) {
181
+ return 0;
182
+ }
183
+
184
+ return new Promise((resolve) => {
185
+ const messageChannel = new MessageChannel();
186
+ messageChannel.port1.onmessage = (event) => {
187
+ resolve(event.data.size || 0);
188
+ };
189
+
190
+ this.registration!.active!.postMessage(
191
+ { type: 'GET_CACHE_SIZE' },
192
+ [messageChannel.port2]
193
+ );
194
+ });
195
+ }
196
+
197
+ /**
198
+ * Check if Service Worker is active
199
+ */
200
+ isActive(): boolean {
201
+ return this.registration !== null && this.registration.active !== null;
202
+ }
203
+
204
+ /**
205
+ * Get Service Worker status
206
+ */
207
+ getStatus(): 'unsupported' | 'inactive' | 'installing' | 'waiting' | 'active' {
208
+ if (!('serviceWorker' in navigator)) {
209
+ return 'unsupported';
210
+ }
211
+
212
+ if (!this.registration) {
213
+ return 'inactive';
214
+ }
215
+
216
+ if (this.registration.installing) {
217
+ return 'installing';
218
+ }
219
+
220
+ if (this.registration.waiting) {
221
+ return 'waiting';
222
+ }
223
+
224
+ if (this.registration.active) {
225
+ return 'active';
226
+ }
227
+
228
+ return 'inactive';
229
+ }
230
+
231
+ /**
232
+ * Get the registration
233
+ */
234
+ getRegistration(): ServiceWorkerRegistration | null {
235
+ return this.registration;
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Create and register a Service Worker manager instance
241
+ */
242
+ export async function registerServiceWorker(
243
+ options?: ServiceWorkerManagerOptions
244
+ ): Promise<ServiceWorkerManager> {
245
+ const manager = new ServiceWorkerManager(options);
246
+ await manager.register();
247
+ return manager;
248
+ }
249
+
250
+ /**
251
+ * Check if Service Workers are supported
252
+ */
253
+ export function isServiceWorkerSupported(): boolean {
254
+ return 'serviceWorker' in navigator && window.isSecureContext;
255
+ }
@@ -0,0 +1,34 @@
1
+ // Re-export common dependencies to ensure singleton instances
2
+ import * as Y from "yjs";
3
+
4
+ // Export Yjs parts
5
+ export { Y };
6
+ export * from "yjs";
7
+
8
+ // Export Awareness for y-protocols/awareness mapping
9
+ export * from "y-protocols/awareness";
10
+
11
+ // Export Sync and Auth protocols (manually to avoid collisions)
12
+ export {
13
+ messageYjsSyncStep1,
14
+ messageYjsSyncStep2,
15
+ messageYjsUpdate,
16
+ writeSyncStep1,
17
+ writeSyncStep2,
18
+ writeUpdate,
19
+ readSyncStep1,
20
+ readSyncStep2,
21
+ } from "y-protocols/sync";
22
+ export * from "y-protocols/auth";
23
+
24
+ // Export Tiptap parts
25
+ export { Editor, Extension } from "@tiptap/core";
26
+ export { default as Document } from "@tiptap/extension-document";
27
+ export { default as Paragraph } from "@tiptap/extension-paragraph";
28
+ export { default as Text } from "@tiptap/extension-text";
29
+ export { default as StarterKit } from "@tiptap/starter-kit";
30
+ export { default as Collaboration } from "@tiptap/extension-collaboration";
31
+ export { CollaborationCursor } from "./extensions/collaboration-cursor.js";
32
+
33
+ // Export Hocuspocus Provider
34
+ export * from "@hocuspocus/provider";