@rgby/collab-vue 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,784 @@
1
+ // @ts-nocheck
2
+ import {
3
+ inject,
4
+ provide,
5
+ ref,
6
+ reactive,
7
+ computed,
8
+ onMounted,
9
+ onBeforeUnmount,
10
+ shallowRef,
11
+ watch,
12
+ markRaw,
13
+ nextTick,
14
+ type InjectionKey,
15
+ type Ref,
16
+ type ShallowRef,
17
+ type ComputedRef
18
+ } from 'vue';
19
+ import * as Y from 'yjs';
20
+ import {
21
+ CollabClient,
22
+ CollabProvider,
23
+ type CollabClientOptions,
24
+ type CommentEvent,
25
+ type SessionData
26
+ } from '@rgby/collab-core';
27
+ import type { Editor, EditorOptions } from '@tiptap/core';
28
+
29
+ // ----------------------------------------------------------------------
30
+ // 1. Setup & Context
31
+ // ----------------------------------------------------------------------
32
+
33
+ export const CollabClientKey: InjectionKey<CollabClient> = Symbol.for('CollabClient');
34
+
35
+ export function provideCollabClient(optionsOrInstance: CollabClientOptions | CollabClient) {
36
+ const client = optionsOrInstance instanceof CollabClient
37
+ ? optionsOrInstance
38
+ : new CollabClient(optionsOrInstance);
39
+
40
+ provide(CollabClientKey, client);
41
+ return client;
42
+ }
43
+
44
+ export function useCollabClient() {
45
+ const client = inject(CollabClientKey);
46
+ if (!client) throw new Error('CollabClient not provided. Call provideCollabClient() at the root.');
47
+ return client;
48
+ }
49
+
50
+ // ----------------------------------------------------------------------
51
+ // 2. Core: useDocument
52
+ // Manages the Y.Doc and Provider lifecycle
53
+ // ----------------------------------------------------------------------
54
+
55
+ export interface UseDocumentReturn {
56
+ doc: ShallowRef<Y.Doc>;
57
+ provider: ShallowRef<CollabProvider | null>;
58
+ status: Ref<'disconnected' | 'connecting' | 'connected'>;
59
+ synced: Ref<boolean>;
60
+ persistenceReady: Ref<boolean>;
61
+ undoManager: ShallowRef<Y.UndoManager | null>;
62
+ connect: () => void;
63
+ disconnect: () => void;
64
+ transact: (callback: (txn: Y.Transaction) => void) => void;
65
+ undo: () => void;
66
+ redo: () => void;
67
+ clearLocalData: () => Promise<void>;
68
+ }
69
+
70
+ export function useDocument(spaceId: string, documentId: string): UseDocumentReturn {
71
+ const client = useCollabClient();
72
+
73
+ // Y.Doc should not be deeply reactive in Vue, so we use shallowRef + markRaw
74
+ const doc = shallowRef<Y.Doc>(markRaw(new Y.Doc()));
75
+ const provider = shallowRef<CollabProvider | null>(null);
76
+ const undoManager = shallowRef<Y.UndoManager | null>(null);
77
+
78
+ const status = ref<'disconnected' | 'connecting' | 'connected'>('disconnected');
79
+ const synced = ref(false);
80
+ const persistenceReady = ref(false);
81
+
82
+ // Track scope for UndoManager to allow filtering (e.g., only undo text, not layout)
83
+ const trackedTypes = new Set<Y.AbstractType<any>>();
84
+
85
+ const connect = async () => {
86
+ if (provider.value) return;
87
+
88
+ const p = await client.getProvider(spaceId, documentId, {
89
+ document: doc.value
90
+ });
91
+
92
+ // Wait for persistence to load before syncing
93
+ await p.waitForPersistence();
94
+ persistenceReady.value = p.isPersistenceReady();
95
+
96
+ // Event Listeners
97
+ const updateStatus = () => { status.value = p.status; };
98
+ const updateSynced = () => { synced.value = p.synced; };
99
+
100
+ p.on('status', updateStatus);
101
+ p.on('synced', updateSynced);
102
+ p.on('disconnect', updateStatus);
103
+
104
+ provider.value = markRaw(p);
105
+
106
+ // Initialize UndoManager
107
+ // We defer this slightly to allow composables to register their types if needed
108
+ nextTick(() => {
109
+ undoManager.value = new Y.UndoManager(
110
+ trackedTypes.size > 0 ? Array.from(trackedTypes) : doc.value.share.values(),
111
+ {
112
+ doc: doc.value,
113
+ captureTransaction: (tr) => tr.origin !== undoManager.value
114
+ }
115
+ );
116
+ });
117
+ };
118
+
119
+ const disconnect = () => {
120
+ provider.value?.destroy();
121
+ provider.value = null;
122
+ undoManager.value?.destroy();
123
+ undoManager.value = null;
124
+ status.value = 'disconnected';
125
+ synced.value = false;
126
+ };
127
+
128
+ onMounted(connect);
129
+ onBeforeUnmount(() => {
130
+ disconnect();
131
+ doc.value.destroy();
132
+ });
133
+
134
+ const transact = (callback: (txn: Y.Transaction) => void) => {
135
+ doc.value.transact(callback, client);
136
+ };
137
+
138
+ const undo = () => undoManager.value?.undo();
139
+ const redo = () => undoManager.value?.redo();
140
+
141
+ const clearLocalData = async () => {
142
+ if (provider.value) {
143
+ await provider.value.clearLocalData();
144
+ }
145
+ };
146
+
147
+ return {
148
+ doc,
149
+ provider,
150
+ status,
151
+ synced,
152
+ persistenceReady,
153
+ undoManager,
154
+ connect,
155
+ disconnect,
156
+ transact,
157
+ undo,
158
+ redo,
159
+ clearLocalData
160
+ };
161
+ }
162
+
163
+ // ----------------------------------------------------------------------
164
+ // 3. Factory: useSyncedMap
165
+ // Reactive wrapper for Y.Map
166
+ // ----------------------------------------------------------------------
167
+
168
+ export function useSyncedMap<T = any>(doc: ShallowRef<Y.Doc>, mapName: string) {
169
+ // We use reactive so properties are directly accessible in templates
170
+ const state = reactive<Record<string, T>>({});
171
+
172
+ let _ymap: Y.Map<T> | null = null;
173
+
174
+ const init = (newDoc: Y.Doc) => {
175
+ if (_ymap) _ymap.unobserve(observer);
176
+
177
+ _ymap = newDoc.getMap<T>(mapName);
178
+
179
+ // Initial Hydration: clear old keys, set new ones
180
+ for (const key in state) delete state[key];
181
+ _ymap.forEach((value, key) => {
182
+ state[key] = value;
183
+ });
184
+
185
+ _ymap.observe(observer);
186
+ };
187
+
188
+ const observer = (event: Y.YMapEvent<T>) => {
189
+ event.keysChanged.forEach(key => {
190
+ if (_ymap!.has(key)) {
191
+ state[key] = _ymap!.get(key)!;
192
+ } else {
193
+ delete state[key];
194
+ }
195
+ });
196
+ };
197
+
198
+ watch(doc, (newDoc) => { if (newDoc) init(newDoc); }, { immediate: true });
199
+
200
+ onBeforeUnmount(() => {
201
+ if (_ymap) _ymap.unobserve(observer);
202
+ });
203
+
204
+ // Mutators
205
+ const set = (key: string, value: T) => _ymap?.set(key, value);
206
+ const remove = (key: string) => _ymap?.delete(key);
207
+ const clear = () => _ymap?.clear();
208
+ const get = (key: string) => _ymap?.get(key);
209
+
210
+ return {
211
+ data: state,
212
+ set,
213
+ remove,
214
+ clear,
215
+ get,
216
+ yMap: computed(() => _ymap)
217
+ };
218
+ }
219
+
220
+ // ----------------------------------------------------------------------
221
+ // 4. Factory: useSyncedArray
222
+ // Reactive wrapper for Y.Array using Deltas for fine-grained DOM updates
223
+ // ----------------------------------------------------------------------
224
+
225
+ export function useSyncedArray<T = any>(doc: ShallowRef<Y.Doc>, arrayName: string) {
226
+ const list = ref<T[]>([]) as Ref<T[]>;
227
+ let _yarray: Y.Array<T> | null = null;
228
+
229
+ const init = (newDoc: Y.Doc) => {
230
+ if (_yarray) _yarray.unobserve(observer);
231
+ _yarray = newDoc.getArray<T>(arrayName);
232
+
233
+ // Initial full copy
234
+ list.value = _yarray.toArray();
235
+ _yarray.observe(observer);
236
+ };
237
+
238
+ const observer = (event: Y.YArrayEvent<T>) => {
239
+ let index = 0;
240
+ event.delta.forEach((op) => {
241
+ if (op.retain) {
242
+ index += op.retain;
243
+ } else if (op.insert) {
244
+ const items = op.insert as T[];
245
+ list.value.splice(index, 0, ...items);
246
+ index += items.length;
247
+ } else if (op.delete) {
248
+ list.value.splice(index, op.delete);
249
+ }
250
+ });
251
+ };
252
+
253
+ watch(doc, (newDoc) => { if (newDoc) init(newDoc); }, { immediate: true });
254
+
255
+ onBeforeUnmount(() => {
256
+ if (_yarray) _yarray.unobserve(observer);
257
+ });
258
+
259
+ // Mutators
260
+ const push = (item: T) => _yarray?.push([item]);
261
+ const unshift = (item: T) => _yarray?.insert(0, [item]);
262
+ const insert = (index: number, items: T[]) => _yarray?.insert(index, items);
263
+ const deleteAt = (index: number, length = 1) => _yarray?.delete(index, length);
264
+
265
+ /**
266
+ * Replaces an item at index. Useful for updating object properties in an array
267
+ * since Y.Array doesn't track deep object property changes automatically.
268
+ */
269
+ const replace = (index: number, item: T) => {
270
+ if (!_yarray) return;
271
+ doc.value.transact(() => {
272
+ _yarray!.delete(index, 1);
273
+ _yarray!.insert(index, [item]);
274
+ });
275
+ };
276
+
277
+ return {
278
+ list,
279
+ push,
280
+ unshift,
281
+ insert,
282
+ deleteAt,
283
+ replace,
284
+ yArray: computed(() => _yarray)
285
+ };
286
+ }
287
+
288
+ // ----------------------------------------------------------------------
289
+ // 5. Factory: useSyncedText
290
+ // Reactive wrapper for Y.Text
291
+ // ----------------------------------------------------------------------
292
+
293
+ export function useSyncedText(doc: ShallowRef<Y.Doc>, textName: string) {
294
+ const text = ref('');
295
+ let _ytext: Y.Text | null = null;
296
+
297
+ const init = (newDoc: Y.Doc) => {
298
+ if (_ytext) _ytext.unobserve(observer);
299
+ _ytext = newDoc.getText(textName);
300
+ text.value = _ytext.toString();
301
+ _ytext.observe(observer);
302
+ };
303
+
304
+ const observer = (event: Y.YTextEvent) => {
305
+ // For strings, we usually just want the current value.
306
+ // Applying strict deltas to a Javascript string is O(N) anyway.
307
+ text.value = _ytext!.toString();
308
+ };
309
+
310
+ watch(doc, (newDoc) => { if (newDoc) init(newDoc); }, { immediate: true });
311
+
312
+ onBeforeUnmount(() => {
313
+ if (_ytext) _ytext.unobserve(observer);
314
+ });
315
+
316
+ const insert = (index: number, content: string, attributes?: Record<string, any>) =>
317
+ _ytext?.insert(index, content, attributes);
318
+
319
+ const deleteText = (index: number, length: number) =>
320
+ _ytext?.delete(index, length);
321
+
322
+ const format = (index: number, length: number, attributes: Record<string, any>) =>
323
+ _ytext?.format(index, length, attributes);
324
+
325
+ return {
326
+ text, // Read-only reactive string
327
+ insert,
328
+ delete: deleteText,
329
+ format,
330
+ yText: computed(() => _ytext)
331
+ };
332
+ }
333
+
334
+ // ----------------------------------------------------------------------
335
+ // 6. Factory: useSyncedXml
336
+ // Exposes Y.XmlFragment (for Tiptap/ProseMirror/Quill)
337
+ // ----------------------------------------------------------------------
338
+
339
+ export function useSyncedXml(doc: ShallowRef<Y.Doc>, xmlName: string) {
340
+ const _yxml = shallowRef<Y.XmlFragment | null>(null);
341
+
342
+ watch(doc, (newDoc) => {
343
+ if (newDoc) {
344
+ _yxml.value = newDoc.getXmlFragment(xmlName);
345
+ }
346
+ }, { immediate: true });
347
+
348
+ return {
349
+ yXml: _yxml // Pass this to Tiptap's Collaboration extension
350
+ };
351
+ }
352
+
353
+ // ----------------------------------------------------------------------
354
+ // 7. Utility: useAwareness (Presence)
355
+ // ----------------------------------------------------------------------
356
+
357
+ export interface AwarenessUser {
358
+ clientId: number;
359
+ user: {
360
+ name: string;
361
+ color: string;
362
+ avatar?: string;
363
+ };
364
+ [key: string]: any;
365
+ }
366
+
367
+ export function useAwareness(provider: ShallowRef<CollabProvider | null>) {
368
+ const states = ref<AwarenessUser[]>([]);
369
+ const currentUser = ref<AwarenessUser | null>(null);
370
+
371
+ const update = () => {
372
+ if (!provider.value) return;
373
+ const awareness = provider.value.awareness;
374
+
375
+ states.value = Array.from(awareness.getStates().entries()).map(([id, state]) => ({
376
+ clientId: id,
377
+ ...state
378
+ })) as AwarenessUser[];
379
+
380
+ const local = awareness.getLocalState();
381
+ if (local) {
382
+ currentUser.value = { clientId: awareness.clientID, ...local } as AwarenessUser;
383
+ }
384
+ };
385
+
386
+ watch(provider, (p, oldP) => {
387
+ if (oldP) oldP.awareness.off('change', update);
388
+ if (p) {
389
+ p.awareness.on('change', update);
390
+ update(); // Initial fetch
391
+ }
392
+ }, { immediate: true });
393
+
394
+ const setLocalState = (state: Partial<AwarenessUser['user'] | any>) => {
395
+ if (!provider.value) return;
396
+ const current = provider.value.awareness.getLocalState() || {};
397
+ provider.value.awareness.setLocalState({ ...current, ...state });
398
+ };
399
+
400
+ return {
401
+ states,
402
+ currentUser,
403
+ setLocalState
404
+ };
405
+ }
406
+
407
+ // ----------------------------------------------------------------------
408
+ // 8. Utility: useLiveComments
409
+ // Hybrid REST + Websocket comments
410
+ // ----------------------------------------------------------------------
411
+
412
+ export function useLiveComments(
413
+ provider: ShallowRef<CollabProvider | null>,
414
+ spaceId: string,
415
+ documentId: string
416
+ ) {
417
+ const client = useCollabClient();
418
+ const threads = ref<any[]>([]); // Replace `any` with your CommentThread interface
419
+ const loading = ref(false);
420
+
421
+ const fetchComments = async () => {
422
+ loading.value = true;
423
+ try {
424
+ const res = await client.comments.list(spaceId, documentId);
425
+ threads.value = res.threads;
426
+ } catch (e) { console.error(e); }
427
+ finally { loading.value = false; }
428
+ };
429
+
430
+ watch(provider, (p) => {
431
+ if (!p) return;
432
+ // Watch for broadcasted events
433
+ const cleanup = p.onComment((event: CommentEvent) => {
434
+ // Simple strategy: refetch on any change from others
435
+ // (You can implement optimistic updates here for 'create' events)
436
+ if (['create', 'update', 'delete', 'resolve'].includes(event.action)) {
437
+ fetchComments();
438
+ }
439
+ });
440
+ fetchComments();
441
+ return () => cleanup();
442
+ }, { immediate: true });
443
+
444
+ const createComment = async (content: string, anchor?: any, parentId?: string) => {
445
+ const comment = await client.comments.create(spaceId, documentId, { content, anchor, parentId });
446
+ provider.value?.sendComment('create', comment);
447
+ // Optimistic local update
448
+ if (parentId) {
449
+ const thread = threads.value.find(t => t.id === parentId);
450
+ if (thread) thread.replies.push(comment);
451
+ } else {
452
+ threads.value.push({ ...comment, replies: [] });
453
+ }
454
+ };
455
+
456
+ return { threads, loading, createComment, refresh: fetchComments };
457
+ }
458
+
459
+ // ----------------------------------------------------------------------
460
+ // 9. useAuth Composable
461
+ // Reactive auth state (only works with managed auth mode)
462
+ // ----------------------------------------------------------------------
463
+
464
+ export interface UseAuthReturn {
465
+ session: ComputedRef<SessionData | null>;
466
+ user: ComputedRef<SessionData['user'] | null>;
467
+ isAuthenticated: ComputedRef<boolean>;
468
+ login: (email: string, password: string) => Promise<SessionData>;
469
+ signup: (email: string, password: string, name: string) => Promise<SessionData>;
470
+ logout: () => Promise<void>;
471
+ }
472
+
473
+ export function useAuth(providedClient?: CollabClient): UseAuthReturn {
474
+ const client = providedClient || useCollabClient();
475
+ const session = ref<SessionData | null>(null);
476
+
477
+ // Access private authManager
478
+ const authManager = (client as any).authManager;
479
+ if (!authManager) {
480
+ throw new Error('useAuth() requires CollabClient to be initialized with managed auth mode');
481
+ }
482
+
483
+ // Get initial session immediately
484
+ session.value = authManager.getSession();
485
+
486
+ // Subscribe to auth changes
487
+ const unsubscribe = authManager.subscribe((newSession: SessionData | null) => {
488
+ session.value = newSession;
489
+ });
490
+
491
+ onBeforeUnmount(() => {
492
+ unsubscribe();
493
+ });
494
+
495
+ return {
496
+ session: computed(() => session.value),
497
+ user: computed(() => session.value?.user ?? null),
498
+ isAuthenticated: computed(() => session.value !== null),
499
+ login: (email: string, password: string) => client.login(email, password),
500
+ signup: (email: string, password: string, name: string) => client.signup(email, password, name),
501
+ logout: () => client.logout()
502
+ };
503
+ }
504
+
505
+ // ----------------------------------------------------------------------
506
+ // 10. useTiptapEditor Composable
507
+ // Complete TipTap editor setup with Y.js + server extensions
508
+ // ----------------------------------------------------------------------
509
+
510
+ export interface UseTiptapEditorOptions {
511
+ spaceId: ComputedRef<string> | Ref<string> | string;
512
+ documentId: ComputedRef<string> | Ref<string> | string;
513
+ extensions?: any[];
514
+ editorOptions?: Partial<EditorOptions>;
515
+ user?: { name?: string; color?: string };
516
+ onReady?: (editor: Editor) => void;
517
+ autoConnect?: boolean;
518
+ }
519
+
520
+ export interface UseTiptapEditorReturn {
521
+ editor: ShallowRef<Editor | null>;
522
+ doc: ShallowRef<Y.Doc>;
523
+ provider: ShallowRef<CollabProvider | null>;
524
+ status: Ref<'disconnected' | 'connecting' | 'connected'>;
525
+ synced: Ref<boolean>;
526
+ persistenceReady: Ref<boolean>;
527
+ loading: Ref<boolean>;
528
+ error: Ref<string>;
529
+ connect: () => Promise<void>;
530
+ disconnect: () => void;
531
+ destroy: () => void;
532
+ clearLocalData: () => Promise<void>;
533
+ }
534
+
535
+ export function useTiptapEditor(options: UseTiptapEditorOptions): UseTiptapEditorReturn {
536
+ const client = useCollabClient();
537
+
538
+ // Normalize refs
539
+ const spaceId = computed(() => {
540
+ const val = options.spaceId;
541
+ return typeof val === 'string' ? val : val.value;
542
+ });
543
+ const documentId = computed(() => {
544
+ const val = options.documentId;
545
+ return typeof val === 'string' ? val : val.value;
546
+ });
547
+
548
+ const editor = shallowRef<Editor | null>(null);
549
+ const doc = shallowRef<Y.Doc>(markRaw(new Y.Doc()));
550
+ const provider = shallowRef<CollabProvider | null>(null);
551
+ const status = ref<'disconnected' | 'connecting' | 'connected'>('disconnected');
552
+ const synced = ref(false);
553
+ const persistenceReady = ref(false);
554
+ const loading = ref(false);
555
+ const error = ref('');
556
+
557
+ // Generate unique tab ID for multi-tab awareness
558
+ const generateTabId = () => {
559
+ return `tab-${Math.random().toString(36).substring(2, 9)}-${Date.now()}`;
560
+ };
561
+
562
+ // Random color for cursor
563
+ const generateColor = () => {
564
+ const colors = [
565
+ '#958DF1', '#F98181', '#FBBC88', '#FAF594', '#70CFF8',
566
+ '#94FADB', '#B9F18D', '#C3E2C2', '#EAECCC', '#AFC8AD'
567
+ ];
568
+ return colors[Math.floor(Math.random() * colors.length)];
569
+ };
570
+
571
+ const connect = async () => {
572
+ if (provider.value) return;
573
+
574
+ loading.value = true;
575
+ error.value = '';
576
+
577
+ try {
578
+ // Dynamically import TipTap extensions first (needed for server extensions)
579
+ const [
580
+ tiptapCore,
581
+ { Collaboration },
582
+ { CollaborationCursor }
583
+ ] = await Promise.all([
584
+ import('@tiptap/core'),
585
+ import('@tiptap/extension-collaboration'),
586
+ import('@rgby/collab-core')
587
+ ]);
588
+ const { Editor: TiptapEditor } = tiptapCore;
589
+
590
+ // Fetch server extensions, providing @tiptap/core for scripts that import it
591
+ const serverExtensions = await client.getExtensions({
592
+ '@tiptap/core': tiptapCore
593
+ });
594
+
595
+ // Get provider
596
+ const p = await client.getProvider(spaceId.value, documentId.value, {
597
+ document: doc.value
598
+ });
599
+
600
+ // Wait for persistence to load
601
+ await p.waitForPersistence();
602
+ persistenceReady.value = p.isPersistenceReady();
603
+
604
+ // Event listeners
605
+ const updateStatus = () => { status.value = p.status; };
606
+ const updateSynced = () => { synced.value = p.synced; };
607
+
608
+ p.on('status', updateStatus);
609
+ p.on('synced', updateSynced);
610
+ p.on('disconnect', updateStatus);
611
+
612
+ provider.value = markRaw(p);
613
+
614
+ // StarterKit is optional - consumer can provide their own extensions
615
+ let StarterKit: any = null;
616
+ try {
617
+ StarterKit = (await import('@tiptap/starter-kit')).default;
618
+ } catch {
619
+ // StarterKit not installed - user should provide extensions via options
620
+ }
621
+
622
+ const fragment = doc.value.getXmlFragment('default');
623
+ const tabId = generateTabId();
624
+ const userName = options.user?.name ?? 'Anonymous';
625
+ const userColor = options.user?.color ?? generateColor();
626
+
627
+ // Create editor
628
+ const baseExtensions = StarterKit
629
+ ? [StarterKit.configure({ undoRedo: false })]
630
+ : [];
631
+
632
+ const editorInstance = new TiptapEditor({
633
+ extensions: [
634
+ ...baseExtensions,
635
+ Collaboration.configure({
636
+ document: doc.value,
637
+ field: 'default'
638
+ }),
639
+ CollaborationCursor.configure({
640
+ provider: provider.value,
641
+ user: {
642
+ name: userName,
643
+ color: userColor,
644
+ clientId: tabId
645
+ }
646
+ }),
647
+ ...serverExtensions,
648
+ ...(options.extensions ?? [])
649
+ ],
650
+ ...options.editorOptions
651
+ });
652
+
653
+ editor.value = markRaw(editorInstance);
654
+ loading.value = false;
655
+
656
+ if (options.onReady) {
657
+ options.onReady(editorInstance);
658
+ }
659
+ } catch (e: any) {
660
+ error.value = e.message || 'Failed to initialize editor';
661
+ loading.value = false;
662
+ console.error('[useTiptapEditor] Error:', e);
663
+ }
664
+ };
665
+
666
+ const disconnect = () => {
667
+ provider.value?.destroy();
668
+ provider.value = null;
669
+ status.value = 'disconnected';
670
+ synced.value = false;
671
+ };
672
+
673
+ const destroy = () => {
674
+ editor.value?.destroy();
675
+ editor.value = null;
676
+ disconnect();
677
+ doc.value.destroy();
678
+ doc.value = markRaw(new Y.Doc());
679
+ };
680
+
681
+ const clearLocalData = async () => {
682
+ if (provider.value) {
683
+ await provider.value.clearLocalData();
684
+ }
685
+ };
686
+
687
+ // Watch for changes to spaceId/documentId and reconnect
688
+ watch([spaceId, documentId], async ([newSpaceId, newDocId], [oldSpaceId, oldDocId]) => {
689
+ if (!newSpaceId || !newDocId) {
690
+ destroy();
691
+ return;
692
+ }
693
+
694
+ // If changed, reconnect
695
+ if (newSpaceId !== oldSpaceId || newDocId !== oldDocId) {
696
+ destroy();
697
+ await connect();
698
+ }
699
+ });
700
+
701
+ // Auto-connect if enabled
702
+ if (options.autoConnect !== false) {
703
+ onMounted(async () => {
704
+ if (spaceId.value && documentId.value) {
705
+ await connect();
706
+ }
707
+ });
708
+ }
709
+
710
+ onBeforeUnmount(() => {
711
+ destroy();
712
+ });
713
+
714
+ return {
715
+ editor,
716
+ doc,
717
+ provider,
718
+ status,
719
+ synced,
720
+ persistenceReady,
721
+ loading,
722
+ error,
723
+ connect,
724
+ disconnect,
725
+ destroy,
726
+ clearLocalData
727
+ };
728
+ }
729
+
730
+ // ----------------------------------------------------------------------
731
+ // 11. Master Composable: useCollaborativeDocument
732
+ // ----------------------------------------------------------------------
733
+
734
+ export function useCollaborativeDocument(spaceId: string, documentId: string) {
735
+ // 1. Initialize Connection
736
+ const {
737
+ doc, provider, status, synced, undoManager,
738
+ connect, disconnect, transact, undo, redo
739
+ } = useDocument(spaceId, documentId);
740
+
741
+ // 2. Initialize Utils
742
+ const awareness = useAwareness(provider);
743
+ const comments = useLiveComments(provider, spaceId, documentId);
744
+
745
+ // 3. Create Factories (closured around 'doc')
746
+ // We return functions that generate the composables so the user can name them
747
+ const useMap = <T>(name: string) => useSyncedMap<T>(doc, name);
748
+ const useArray = <T>(name: string) => useSyncedArray<T>(doc, name);
749
+ const useText = (name: string) => useSyncedText(doc, name);
750
+ const useXml = (name: string) => useSyncedXml(doc, name);
751
+
752
+ return {
753
+ // Connection & Core
754
+ doc,
755
+ provider,
756
+ status,
757
+ synced,
758
+ connect,
759
+ disconnect,
760
+
761
+ // History
762
+ undo,
763
+ redo,
764
+ transact,
765
+ canUndo: computed(() => undoManager.value?.undoStack.length ?? 0 > 0),
766
+ canRedo: computed(() => undoManager.value?.redoStack.length ?? 0 > 0),
767
+
768
+ // Presence
769
+ users: awareness.states,
770
+ me: awareness.currentUser,
771
+ updatePresence: awareness.setLocalState,
772
+
773
+ // Comments
774
+ comments: comments.threads,
775
+ loadingComments: comments.loading,
776
+ createComment: comments.createComment,
777
+
778
+ // Factories
779
+ useMap,
780
+ useArray,
781
+ useText,
782
+ useXml
783
+ };
784
+ }