mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-13 12:55:19 +00:00
feat: refactor editor's collaboration to reduce package size and minimize errors
This commit is contained in:
parent
293531549c
commit
7f6b260c35
3 changed files with 350 additions and 336 deletions
|
|
@ -114,19 +114,6 @@
|
||||||
import { Decoration, DecorationSet } from 'prosemirror-view';
|
import { Decoration, DecorationSet } from 'prosemirror-view';
|
||||||
import { Editor, Extension, mergeAttributes } from '@tiptap/core';
|
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 { AIAutocompletion } from './RichTextInput/AutoCompletion.js';
|
||||||
|
|
||||||
import StarterKit from '@tiptap/starter-kit';
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
|
|
@ -153,6 +140,7 @@
|
||||||
|
|
||||||
import { PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants';
|
import { PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants';
|
||||||
import { all, createLowlight } from 'lowlight';
|
import { all, createLowlight } from 'lowlight';
|
||||||
|
import type { SocketIOCollaborationProvider } from './RichTextInput/Collaboration';
|
||||||
|
|
||||||
export let oncompositionstart = (e) => {};
|
export let oncompositionstart = (e) => {};
|
||||||
export let oncompositionend = (e) => {};
|
export let oncompositionend = (e) => {};
|
||||||
|
|
@ -161,7 +149,7 @@
|
||||||
// create a lowlight instance with all languages loaded
|
// create a lowlight instance with all languages loaded
|
||||||
const lowlight = createLowlight(all);
|
const lowlight = createLowlight(all);
|
||||||
|
|
||||||
export let editor = null;
|
export let editor: Editor | null = null;
|
||||||
|
|
||||||
export let socket = null;
|
export let socket = null;
|
||||||
export let user = null;
|
export let user = null;
|
||||||
|
|
@ -264,325 +252,11 @@
|
||||||
let jsonValue = '';
|
let jsonValue = '';
|
||||||
let mdValue = '';
|
let mdValue = '';
|
||||||
|
|
||||||
let lastSelectionBookmark = null;
|
let provider: SocketIOCollaborationProvider | null = null;
|
||||||
|
|
||||||
// Yjs setup
|
let floatingMenuElement: Element | null = null;
|
||||||
let ydoc = null;
|
let bubbleMenuElement: Element | null = null;
|
||||||
let yXmlFragment = null;
|
let element: Element | null = 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;
|
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
throwOnError: false
|
throwOnError: false
|
||||||
|
|
@ -970,8 +644,9 @@
|
||||||
|
|
||||||
console.log('content', content);
|
console.log('content', content);
|
||||||
|
|
||||||
if (collaboration) {
|
if (collaboration && documentId && socket && user) {
|
||||||
initializeCollaboration();
|
const { SocketIOCollaborationProvider } = await import('./RichTextInput/Collaboration');
|
||||||
|
provider = new SocketIOCollaborationProvider(documentId, socket, user, content);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(bubbleMenuElement, floatingMenuElement);
|
console.log(bubbleMenuElement, floatingMenuElement);
|
||||||
|
|
@ -1064,7 +739,7 @@
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...(collaboration ? [YjsCollaboration] : [])
|
...(collaboration && provider ? [provider.getEditorExtension()] : [])
|
||||||
],
|
],
|
||||||
content: collaboration ? undefined : content,
|
content: collaboration ? undefined : content,
|
||||||
autofocus: messageInput ? true : false,
|
autofocus: messageInput ? true : false,
|
||||||
|
|
@ -1337,6 +1012,8 @@
|
||||||
enablePasteRules: richText
|
enablePasteRules: richText
|
||||||
});
|
});
|
||||||
|
|
||||||
|
provider?.setEditor(editor, () => ({ md: mdValue, html: htmlValue, json: jsonValue }));
|
||||||
|
|
||||||
if (messageInput) {
|
if (messageInput) {
|
||||||
selectTemplate();
|
selectTemplate();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
337
src/lib/components/common/RichTextInput/Collaboration.ts
Normal file
337
src/lib/components/common/RichTextInput/Collaboration.ts
Normal file
|
|
@ -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<number, any>;
|
||||||
|
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<number, any> = {};
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -277,7 +277,7 @@ type PromptSuggestion = {
|
||||||
title: [string, string];
|
title: [string, string];
|
||||||
};
|
};
|
||||||
|
|
||||||
type SessionUser = {
|
export type SessionUser = {
|
||||||
permissions: any;
|
permissions: any;
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue