{
e.stopPropagation();
diff --git a/src/lib/components/common/RichTextInput.svelte b/src/lib/components/common/RichTextInput.svelte
index d6f59ab34f..468813aeaf 100644
--- a/src/lib/components/common/RichTextInput.svelte
+++ b/src/lib/components/common/RichTextInput.svelte
@@ -114,19 +114,6 @@
import { Decoration, DecorationSet } from 'prosemirror-view';
import { Editor, Extension, mergeAttributes } from '@tiptap/core';
- // Yjs imports
- import * as Y from 'yjs';
- import {
- ySyncPlugin,
- yCursorPlugin,
- yUndoPlugin,
- undo,
- redo,
- prosemirrorJSONToYDoc,
- yDocToProsemirrorJSON
- } from 'y-prosemirror';
- import { keymap } from 'prosemirror-keymap';
-
import { AIAutocompletion } from './RichTextInput/AutoCompletion.js';
import StarterKit from '@tiptap/starter-kit';
@@ -153,6 +140,7 @@
import { PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants';
import { all, createLowlight } from 'lowlight';
+ import type { SocketIOCollaborationProvider } from './RichTextInput/Collaboration';
export let oncompositionstart = (e) => {};
export let oncompositionend = (e) => {};
@@ -161,7 +149,7 @@
// create a lowlight instance with all languages loaded
const lowlight = createLowlight(all);
- export let editor = null;
+ export let editor: Editor | null = null;
export let socket = null;
export let user = null;
@@ -264,325 +252,11 @@
let jsonValue = '';
let mdValue = '';
- let lastSelectionBookmark = null;
+ let provider: SocketIOCollaborationProvider | null = null;
- // Yjs setup
- let ydoc = null;
- let yXmlFragment = null;
- let awareness = null;
-
- const getEditorInstance = async () => {
- return new Promise((resolve) => {
- setTimeout(() => {
- resolve(editor);
- }, 0);
- });
- };
-
- // Custom Yjs Socket.IO provider
- class SocketIOProvider {
- constructor(doc, documentId, socket, user) {
- this.doc = doc;
- this.documentId = documentId;
- this.socket = socket;
- this.user = user;
- this.isConnected = false;
- this.synced = false;
-
- this.setupEventListeners();
- }
-
- generateUserColor() {
- const colors = [
- '#FF6B6B',
- '#4ECDC4',
- '#45B7D1',
- '#96CEB4',
- '#FFEAA7',
- '#DDA0DD',
- '#98D8C8',
- '#F7DC6F',
- '#BB8FCE',
- '#85C1E9'
- ];
- return colors[Math.floor(Math.random() * colors.length)];
- }
-
- joinDocument() {
- const userColor = this.generateUserColor();
- this.socket.emit('ydoc:document:join', {
- document_id: this.documentId,
- user_id: this.user?.id,
- user_name: this.user?.name,
- user_color: userColor
- });
-
- // Set user awareness info
- if (awareness && this.user) {
- awareness.setLocalStateField('user', {
- name: `${this.user.name}`,
- color: userColor,
- id: this.socket.id
- });
- }
- }
-
- setupEventListeners() {
- // Listen for document updates from server
- this.socket.on('ydoc:document:update', (data) => {
- if (data.document_id === this.documentId && data.socket_id !== this.socket.id) {
- try {
- const update = new Uint8Array(data.update);
- Y.applyUpdate(this.doc, update);
- } catch (error) {
- console.error('Error applying Yjs update:', error);
- }
- }
- });
-
- // Listen for document state from server
- this.socket.on('ydoc:document:state', async (data) => {
- if (data.document_id === this.documentId) {
- try {
- if (data.state) {
- const state = new Uint8Array(data.state);
-
- if (state.length === 2 && state[0] === 0 && state[1] === 0) {
- // Empty state, check if we have content to initialize
- // check if editor empty as well
- // const editor = await getEditorInstance();
-
- const isEmptyEditor = !editor || editor.getText().trim() === '';
- if (isEmptyEditor) {
- if (content && (data?.sessions ?? ['']).length === 1) {
- const editorYdoc = prosemirrorJSONToYDoc(editor.schema, content);
- if (editorYdoc) {
- Y.applyUpdate(this.doc, Y.encodeStateAsUpdate(editorYdoc));
- }
- }
- } else {
- // If the editor already has content, we don't need to send an empty state
- if (this.doc.getXmlFragment('prosemirror').length > 0) {
- this.socket.emit('ydoc:document:update', {
- document_id: this.documentId,
- user_id: this.user?.id,
- socket_id: this.socket.id,
- update: Y.encodeStateAsUpdate(this.doc)
- });
- } else {
- console.warn('Yjs document is empty, not sending state.');
- }
- }
- } else {
- Y.applyUpdate(this.doc, state, 'server');
- }
- }
- this.synced = true;
- } catch (error) {
- console.error('Error applying Yjs state:', error);
-
- this.synced = false;
- this.socket.emit('ydoc:document:state', {
- document_id: this.documentId
- });
- }
- }
- });
-
- // Listen for awareness updates
- this.socket.on('ydoc:awareness:update', (data) => {
- if (data.document_id === this.documentId && awareness) {
- try {
- const awarenessUpdate = new Uint8Array(data.update);
- awareness.applyUpdate(awarenessUpdate, 'server');
- } catch (error) {
- console.error('Error applying awareness update:', error);
- }
- }
- });
-
- // Handle connection events
- this.socket.on('connect', this.onConnect);
- this.socket.on('disconnect', this.onDisconnect);
-
- // Listen for document updates from Yjs
- this.doc.on('update', async (update, origin) => {
- if (origin !== 'server' && this.isConnected) {
- await tick(); // Ensure the DOM is updated before sending
- this.socket.emit('ydoc:document:update', {
- document_id: this.documentId,
- user_id: this.user?.id,
- socket_id: this.socket.id,
- update: Array.from(update),
- data: {
- content: {
- md: mdValue,
- html: htmlValue,
- json: jsonValue
- }
- }
- });
- }
- });
-
- // Listen for awareness updates from Yjs
- if (awareness) {
- awareness.on('change', ({ added, updated, removed }, origin) => {
- if (origin !== 'server' && this.isConnected) {
- const changedClients = added.concat(updated).concat(removed);
- const awarenessUpdate = awareness.encodeUpdate(changedClients);
- this.socket.emit('ydoc:awareness:update', {
- document_id: this.documentId,
- user_id: this.socket.id,
- update: Array.from(awarenessUpdate)
- });
- }
- });
- }
-
- if (this.socket.connected) {
- this.isConnected = true;
- this.joinDocument();
- }
- }
-
- onConnect = () => {
- this.isConnected = true;
- this.joinDocument();
- };
-
- onDisconnect = () => {
- this.isConnected = false;
- this.synced = false;
- };
-
- destroy() {
- this.socket.off('ydoc:document:update');
- this.socket.off('ydoc:document:state');
- this.socket.off('ydoc:awareness:update');
- this.socket.off('connect', this.onConnect);
- this.socket.off('disconnect', this.onDisconnect);
-
- if (this.isConnected) {
- this.socket.emit('ydoc:document:leave', {
- document_id: this.documentId,
- user_id: this.user?.id
- });
- }
- }
- }
-
- let provider = null;
-
- // Simple awareness implementation
- class SimpleAwareness {
- constructor(yDoc) {
- // Yjs awareness expects clientID (not clientId) property
- this.clientID = yDoc ? yDoc.clientID : Math.floor(Math.random() * 0xffffffff);
- // Map from clientID (number) to state (object)
- this._states = new Map(); // _states, not states; will make getStates() for compat
- this._updateHandlers = [];
- this._localState = {};
- // As in Yjs Awareness, add our local state to the states map from the start:
- this._states.set(this.clientID, this._localState);
- }
- on(event, handler) {
- if (event === 'change') this._updateHandlers.push(handler);
- }
- off(event, handler) {
- if (event === 'change') {
- const i = this._updateHandlers.indexOf(handler);
- if (i !== -1) this._updateHandlers.splice(i, 1);
- }
- }
- getLocalState() {
- return this._states.get(this.clientID) || null;
- }
- getStates() {
- // Yjs returns a Map (clientID->state)
- return this._states;
- }
- setLocalStateField(field, value) {
- let localState = this._states.get(this.clientID);
- if (!localState) {
- localState = {};
- this._states.set(this.clientID, localState);
- }
- localState[field] = value;
- // After updating, fire 'update' event to all handlers
- for (const cb of this._updateHandlers) {
- // Follows Yjs Awareness ({ added, updated, removed }, origin)
- cb({ added: [], updated: [this.clientID], removed: [] }, 'local');
- }
- }
- applyUpdate(update, origin) {
- // Very simple: Accepts a serialized JSON state for now as Uint8Array
- try {
- const str = new TextDecoder().decode(update);
- const obj = JSON.parse(str);
- // Should be a plain object: { clientID: state, ... }
- for (const [k, v] of Object.entries(obj)) {
- this._states.set(+k, v);
- }
- for (const cb of this._updateHandlers) {
- cb({ added: [], updated: Array.from(Object.keys(obj)).map(Number), removed: [] }, origin);
- }
- } catch (e) {
- console.warn('SimpleAwareness: Could not decode update:', e);
- }
- }
- encodeUpdate(clients) {
- // Encodes the states for the given clientIDs as Uint8Array (JSON)
- const obj = {};
- for (const id of clients || Array.from(this._states.keys())) {
- const st = this._states.get(id);
- if (st) obj[id] = st;
- }
- const json = JSON.stringify(obj);
- return new TextEncoder().encode(json);
- }
- }
-
- // Yjs collaboration extension
- const YjsCollaboration = Extension.create({
- name: 'yjsCollaboration',
-
- addProseMirrorPlugins() {
- if (!collaboration || !yXmlFragment) return [];
-
- const plugins = [
- ySyncPlugin(yXmlFragment),
- yUndoPlugin(),
- keymap({
- 'Mod-z': undo,
- 'Mod-y': redo,
- 'Mod-Shift-z': redo
- })
- ];
-
- if (awareness) {
- plugins.push(yCursorPlugin(awareness));
- }
-
- return plugins;
- }
- });
-
- function initializeCollaboration() {
- if (!collaboration) return;
-
- // Create Yjs document
- ydoc = new Y.Doc();
- yXmlFragment = ydoc.getXmlFragment('prosemirror');
- awareness = new SimpleAwareness(ydoc);
-
- // Create custom Socket.IO provider
- provider = new SocketIOProvider(ydoc, documentId, socket, user);
- }
-
- let floatingMenuElement = null;
- let bubbleMenuElement = null;
- let element;
+ let floatingMenuElement: Element | null = null;
+ let bubbleMenuElement: Element | null = null;
+ let element: Element | null = null;
const options = {
throwOnError: false
@@ -970,8 +644,9 @@
console.log('content', content);
- if (collaboration) {
- initializeCollaboration();
+ if (collaboration && documentId && socket && user) {
+ const { SocketIOCollaborationProvider } = await import('./RichTextInput/Collaboration');
+ provider = new SocketIOCollaborationProvider(documentId, socket, user, content);
}
console.log(bubbleMenuElement, floatingMenuElement);
@@ -1064,7 +739,7 @@
})
]
: []),
- ...(collaboration ? [YjsCollaboration] : [])
+ ...(collaboration && provider ? [provider.getEditorExtension()] : [])
],
content: collaboration ? undefined : content,
autofocus: messageInput ? true : false,
@@ -1337,6 +1012,8 @@
enablePasteRules: richText
});
+ provider?.setEditor(editor, () => ({ md: mdValue, html: htmlValue, json: jsonValue }));
+
if (messageInput) {
selectTemplate();
}
diff --git a/src/lib/components/common/RichTextInput/Collaboration.ts b/src/lib/components/common/RichTextInput/Collaboration.ts
new file mode 100644
index 0000000000..7c7b7a48d9
--- /dev/null
+++ b/src/lib/components/common/RichTextInput/Collaboration.ts
@@ -0,0 +1,337 @@
+import * as Y from 'yjs';
+import {
+ ySyncPlugin,
+ yCursorPlugin,
+ yUndoPlugin,
+ undo,
+ redo,
+ prosemirrorJSONToYDoc
+} from 'y-prosemirror';
+import type { Socket } from 'socket.io-client';
+import type { Awareness } from 'y-protocols/awareness';
+import type { SessionUser } from '$lib/stores';
+import { Editor, Extension } from '@tiptap/core';
+import { keymap } from 'prosemirror-keymap';
+import { tick } from 'svelte';
+
+const USER_COLORS = [
+ '#FF6B6B',
+ '#4ECDC4',
+ '#45B7D1',
+ '#96CEB4',
+ '#FFEAA7',
+ '#DDA0DD',
+ '#98D8C8',
+ '#F7DC6F',
+ '#BB8FCE',
+ '#85C1E9'
+];
+const generateUserColor = () => {
+ return USER_COLORS[Math.floor(Math.random() * USER_COLORS.length)];
+};
+
+export type EditorContentGetter = () => {
+ md: string;
+ html: string;
+ json: string;
+};
+
+// Custom Yjs Socket.IO provider
+export class SocketIOCollaborationProvider {
+ private readonly doc = new Y.Doc();
+ private readonly awareness = new SimpleAwareness(this.doc);
+ private isConnected = false;
+ private synced = false;
+ private editor: Editor | null = null;
+ private editorContentGetter: EditorContentGetter | null = null;
+
+ constructor(
+ private readonly documentId: string,
+ private readonly socket: Socket,
+ private readonly user: SessionUser,
+ private readonly initialContent: string | null = null
+ ) {
+ this.setupEventListeners();
+ }
+
+ public getEditorExtension() {
+ return Extension.create({
+ name: 'yjsCollaboration',
+
+ addProseMirrorPlugins: () => {
+ const yXmlFragment = this.doc.getXmlFragment('prosemirror');
+ if (!yXmlFragment) return [];
+
+ const plugins = [
+ ySyncPlugin(yXmlFragment),
+ yUndoPlugin(),
+ keymap({
+ 'Mod-z': undo,
+ 'Mod-y': redo,
+ 'Mod-Shift-z': redo
+ })
+ ];
+
+ plugins.push(yCursorPlugin(this.awareness as unknown as Awareness));
+
+ return plugins;
+ }
+ });
+ }
+
+ public setEditor(editor: Editor, editorContentGetter: EditorContentGetter) {
+ this.editor = editor;
+ this.editorContentGetter = editorContentGetter;
+ }
+
+ private joinDocument() {
+ const userColor = generateUserColor();
+ this.socket.emit('ydoc:document:join', {
+ document_id: this.documentId,
+ user_id: this.user?.id,
+ user_name: this.user?.name,
+ user_color: userColor
+ });
+
+ // Set user awareness info
+ if (this.user) {
+ this.awareness.setLocalStateField('user', {
+ name: `${this.user.name}`,
+ color: userColor,
+ id: this.socket.id
+ });
+ }
+ }
+
+ private setupEventListeners() {
+ // Listen for document updates from server
+ this.socket.on('ydoc:document:update', (data) => {
+ if (data.document_id === this.documentId && data.socket_id !== this.socket.id) {
+ try {
+ const update = new Uint8Array(data.update);
+ Y.applyUpdate(this.doc, update);
+ } catch (error) {
+ console.error('Error applying Yjs update:', error);
+ }
+ }
+ });
+
+ // Listen for document state from server
+ this.socket.on('ydoc:document:state', async (data) => {
+ if (data.document_id === this.documentId) {
+ try {
+ if (data.state) {
+ const state = new Uint8Array(data.state);
+
+ if (state.length === 2 && state[0] === 0 && state[1] === 0) {
+ // Empty state, check if we have content to initialize
+ // check if editor empty as well
+ // const editor = await getEditorInstance();
+
+ const isEmptyEditor = !this.editor?.getText().trim();
+ if (isEmptyEditor && this.editor) {
+ if (this.initialContent && (data?.sessions ?? ['']).length === 1) {
+ const editorYdoc = prosemirrorJSONToYDoc(this.editor.schema, this.initialContent);
+ if (editorYdoc) {
+ Y.applyUpdate(this.doc, Y.encodeStateAsUpdate(editorYdoc));
+ }
+ }
+ } else {
+ // If the editor already has content, we don't need to send an empty state
+ if (this.doc.getXmlFragment('prosemirror').length > 0) {
+ this.socket.emit('ydoc:document:update', {
+ document_id: this.documentId,
+ user_id: this.user?.id,
+ socket_id: this.socket.id,
+ update: Y.encodeStateAsUpdate(this.doc)
+ });
+ } else {
+ console.warn('Yjs document is empty, not sending state.');
+ }
+ }
+ } else {
+ Y.applyUpdate(this.doc, state, 'server');
+ }
+ }
+ this.synced = true;
+ } catch (error) {
+ console.error('Error applying Yjs state:', error);
+
+ this.synced = false;
+ this.socket.emit('ydoc:document:state', {
+ document_id: this.documentId
+ });
+ }
+ }
+ });
+
+ // Listen for awareness updates
+ this.socket.on('ydoc:awareness:update', (data) => {
+ if (data.document_id === this.documentId) {
+ try {
+ const awarenessUpdate = new Uint8Array(data.update);
+ this.awareness.applyUpdate(awarenessUpdate, 'server');
+ } catch (error) {
+ console.error('Error applying awareness update:', error);
+ }
+ }
+ });
+
+ // Handle connection events
+ this.socket.on('connect', this.onConnect);
+ this.socket.on('disconnect', this.onDisconnect);
+
+ // Listen for document updates from Yjs
+ this.doc.on('update', async (update, origin) => {
+ if (this.editor && origin !== 'server' && this.isConnected) {
+ await tick(); // Ensure the DOM is updated before sending
+ this.socket.emit('ydoc:document:update', {
+ document_id: this.documentId,
+ user_id: this.user?.id,
+ socket_id: this.socket.id,
+ update: Array.from(update),
+ data: {
+ content: this.editorContentGetter?.() ?? {
+ md: '',
+ html: '',
+ json: ''
+ }
+ }
+ });
+ }
+ });
+
+ // Listen for awareness updates from Yjs
+ this.awareness.on(
+ 'change',
+ (
+ { added, updated, removed }: { added: number[]; updated: number[]; removed: number[] },
+ origin: string
+ ) => {
+ if (origin !== 'server' && this.isConnected) {
+ const changedClients = added.concat(updated).concat(removed);
+ const awarenessUpdate = this.awareness.encodeUpdate(changedClients);
+ this.socket.emit('ydoc:awareness:update', {
+ document_id: this.documentId,
+ user_id: this.socket.id,
+ update: Array.from(awarenessUpdate)
+ });
+ }
+ }
+ );
+
+ if (this.socket.connected) {
+ this.isConnected = true;
+ this.joinDocument();
+ }
+ }
+
+ private readonly onConnect = () => {
+ this.isConnected = true;
+ this.joinDocument();
+ };
+
+ private readonly onDisconnect = () => {
+ this.isConnected = false;
+ this.synced = false;
+ };
+
+ public destroy() {
+ this.socket.off('ydoc:document:update');
+ this.socket.off('ydoc:document:state');
+ this.socket.off('ydoc:awareness:update');
+ this.socket.off('connect', this.onConnect);
+ this.socket.off('disconnect', this.onDisconnect);
+
+ if (this.isConnected) {
+ this.socket.emit('ydoc:document:leave', {
+ document_id: this.documentId,
+ user_id: this.user?.id
+ });
+ }
+
+ this.editor = null;
+ this.editorContentGetter = null;
+ }
+}
+
+// Simple awareness implementation
+class SimpleAwareness {
+ public readonly clientID: number;
+ private readonly _states: Map
;
+ private readonly _updateHandlers: any[];
+ private readonly _localState: any;
+
+ public constructor(public readonly doc: Y.Doc) {
+ // Yjs awareness expects clientID (not clientId) property
+ this.clientID = doc.clientID ? doc.clientID : Math.floor(Math.random() * 0xffffffff);
+ // Map from clientID (number) to state (object)
+ this._states = new Map(); // _states, not states; will make getStates() for compat
+ this._updateHandlers = [];
+ this._localState = {};
+ // As in Yjs Awareness, add our local state to the states map from the start:
+ this._states.set(this.clientID, this._localState);
+ }
+
+ public on(event: string, handler: any) {
+ if (event === 'change') this._updateHandlers.push(handler);
+ }
+
+ public off(event: string, handler: any) {
+ if (event === 'change') {
+ const i = this._updateHandlers.indexOf(handler);
+ if (i !== -1) this._updateHandlers.splice(i, 1);
+ }
+ }
+
+ public getLocalState() {
+ return this._states.get(this.clientID) || null;
+ }
+
+ public getStates() {
+ // Yjs returns a Map (clientID->state)
+ return this._states;
+ }
+
+ public setLocalStateField(field: string, value: any) {
+ let localState = this._states.get(this.clientID);
+ if (!localState) {
+ localState = {};
+ this._states.set(this.clientID, localState);
+ }
+ localState[field] = value;
+ // After updating, fire 'update' event to all handlers
+ for (const cb of this._updateHandlers) {
+ // Follows Yjs Awareness ({ added, updated, removed }, origin)
+ cb({ added: [], updated: [this.clientID], removed: [] }, 'local');
+ }
+ }
+
+ public applyUpdate(update: Uint8Array, origin: string) {
+ // Very simple: Accepts a serialized JSON state for now as Uint8Array
+ try {
+ const str = new TextDecoder().decode(update);
+ const obj = JSON.parse(str);
+ // Should be a plain object: { clientID: state, ... }
+ for (const [k, v] of Object.entries(obj)) {
+ this._states.set(+k, v);
+ }
+ for (const cb of this._updateHandlers) {
+ cb({ added: [], updated: Array.from(Object.keys(obj)).map(Number), removed: [] }, origin);
+ }
+ } catch (e) {
+ console.warn('SimpleAwareness: Could not decode update:', e);
+ }
+ }
+
+ public encodeUpdate(clients: number[]) {
+ // Encodes the states for the given clientIDs as Uint8Array (JSON)
+ const obj: Record = {};
+ for (const id of clients || Array.from(this._states.keys())) {
+ const st = this._states.get(id);
+ if (st) obj[id] = st;
+ }
+ const json = JSON.stringify(obj);
+ return new TextEncoder().encode(json);
+ }
+}
diff --git a/src/lib/components/layout/SearchModal.svelte b/src/lib/components/layout/SearchModal.svelte
index 3eb6687e41..9e55150321 100644
--- a/src/lib/components/layout/SearchModal.svelte
+++ b/src/lib/components/layout/SearchModal.svelte
@@ -219,8 +219,8 @@
-