diff --git a/app/controllers/socket/Code.controller.js b/app/controllers/socket/Code.controller.js new file mode 100644 index 0000000..b0e789b --- /dev/null +++ b/app/controllers/socket/Code.controller.js @@ -0,0 +1,10 @@ +const EditorController = require('./Editor.controller') + +class CodeController extends EditorController { + async _get_resource(id) { + const Codium = this.models.get('api:Codium') + return Codium.findOne({ UUID: id }) + } +} + +module.exports = exports = CodeController diff --git a/app/controllers/socket/Editor.controller.js b/app/controllers/socket/Editor.controller.js new file mode 100644 index 0000000..57c6c62 --- /dev/null +++ b/app/controllers/socket/Editor.controller.js @@ -0,0 +1,241 @@ +const SocketController = require('flitter-socket/Controller') +const uuid = require('uuid').v4 + +function getColor(){ + return "hsl(" + 360 * Math.random() + ',' + + (25 + 70 * Math.random()) + '%,' + + (85 + 10 * Math.random()) + '%)' +} + +class EditorGroup { + constructor(resource_id, resource) { + this.id = resource_id + this.resource = resource + this.connections = {} + this.connection_id_x_session = {} + } + + join(connection_manager) { + this.connections[connection_manager.id] = connection_manager + this.connection_id_x_session[connection_manager.id] = {} + + connection_manager.on_close().then(() => { + delete this.connection_id_x_session[connection_manager.id] + delete this.connections[connection_manager.id] + this.blast_editor_group_users() + }) + } + + has(connection_manager) { + return !!this.connections[connection_manager.id] + } + + unique_users() { + const users = {} + const conns = Object.values(this.connections) + + for ( const conn of conns ) { + const session = this.session(conn) + const user = conn.request.user + if ( user && !users[user.uuid] ) { + if ( !session.user_color ) { + session.user_color = getColor() // '#' + Math.floor(Math.random() * 16777215).toString(16) + while ( session.user_color.length < 7 ) { + session.user_color += '5' + } + } + + users[user.uuid] = { user, color: session.user_color } + } + } + + return Object.values(users) + } + + session(conn) { + return this.connection_id_x_session[conn.id] + } + + blast_editor_group_users() { + const conns = Object.values(this.connections) + const unique_users = this.unique_users() + console.log('blast_editor_group_users', unique_users) + + for ( const conn of conns ) { + const users = unique_users.filter(data => data.user.uuid !== conn.request.user.uuid) + .map(data => { + const { user, color } = data + return { + uuid: user.uuid, + uid: user.uid, + display: user.uid, + color, + } + }) + + conn._request('setEditorGroupUsers', { users }) + } + } + + blast_operations(operations, exclude_connection_id) { + const conns = Object.values(this.connections) + + for ( const conn of conns ) { + if ( conn.id === exclude_connection_id ) continue; + + conn._request('applyRemoteOperation', { operations }) + } + } + + blast_position(position, uuid, exclude_connection_id) { + const conns = Object.values(this.connections) + + for ( const conn of conns ) { + if ( conn.id === exclude_connection_id ) continue; + + conn._request('updateCursorPosition', { position, uuid }) + } + } + + blast_selection(startPosition, endPosition, uuid, exclude_connection_id) { + const conns = Object.values(this.connections) + + for ( const conn of conns ) { + if ( conn.id === exclude_connection_id ) continue; + + conn._request('updateSelection', { startPosition, endPosition, uuid }) + } + } +} + +class EditorController extends SocketController { + editor_groups = {} + + static get services() { + return [...super.services, 'output', 'models'] + } + + ping(transaction, socket) { + this.output.info('Got norm editor socket ping.') + + transaction.status(200) + .message('Pong!') + .send(transaction.incoming) + } + + async apply(transaction, socket) { + const group = this._get_editor_group_or_fail(transaction) + if ( !group ) return + + // FIXME validate + const operations = transaction.incoming.operations.map(x => typeof x === 'string' ? JSON.parse(x) : x) + await group.blast_operations(operations, transaction.cm.id) + + transaction.send() + } + + async update_cursor(transaction, socket) { + const group = this._get_editor_group_or_fail(transaction) + if ( !group ) return + + // FIXME validate + const position = transaction.incoming.position + const uuid = transaction.incoming.uuid + await group.blast_position(position, uuid, transaction.cm.id) + + transaction.send() + } + + async update_selection(transaction, socket) { + const group = this._get_editor_group_or_fail(transaction) + if ( !group ) return + + // FIXME validate + const start = transaction.incoming.startPosition + const end = transaction.incoming.endPosition + const uuid = transaction.incoming.uuid + await group.blast_selection(start, end, uuid, transaction.cm.id) + + transaction.send() + } + + async subscribe(transaction, socket) { + if ( !transaction.incoming.resource_id ) { + return transaction.status(400) + .message('Missing resource ID.') + .send() + } + + const resource = await this._get_resource(transaction.incoming.resource_id) + if ( !resource ) { + return transaction.status(404) + .message('Invalid resource ID.') + .send() + } + + // Try to look up an existing editor group + let editor_group = this.editor_groups[transaction.incoming.resource_id] + + if ( !editor_group ) { + // create a new editor group + editor_group = new EditorGroup(transaction.incoming.resource_id, resource) + this.editor_groups[transaction.incoming.resource_id] = editor_group + } + + if ( !editor_group.has(transaction.cm) ) + editor_group.join(transaction.cm) + + transaction.status(200) + .message('Joined editor group.') + .send({ + editor_group_id: transaction.incoming.resource_id, + local_user: { + uid: transaction.cm.request.user.uid, + uuid: transaction.cm.request.user.uuid, + color: 'black', + display: transaction.cm.request.user.uid, + } + }) + + editor_group.blast_editor_group_users() + } + + async _get_resource(id) { + const Node = this.models.get('api:Node') + return Node.findOne({ UUID: id }) + } + + _get_editor_group_or_fail(transaction) { + if ( !transaction.incoming.editor_group_id ) { + transaction.status(400) + .message('Missing editor_group_id.') + .send() + + return + } + + const editor_group = this.editor_groups[transaction.incoming.editor_group_id] + if ( !editor_group || !editor_group.has(transaction.cm) ) { + transaction.status(400) + .message('Invalid editor_group_id.') + .send() + + return + } + + return editor_group + } + + _client_connected(connection_manager) { + super._client_connected(connection_manager) + + this.output.debug('New client connected to editor socket.') + + if ( !connection_manager.request?.session?.auth?.user_id ) { + delete this.connections[connection_manager.id] + connection_manager.socket.close() + } + } +} + +module.exports = exports = EditorController diff --git a/app/routing/routers/api/v1/socket.routes.js b/app/routing/routers/api/v1/socket.routes.js index 46869c7..1623e26 100644 --- a/app/routing/routers/api/v1/socket.routes.js +++ b/app/routing/routers/api/v1/socket.routes.js @@ -10,5 +10,11 @@ module.exports = exports = { '/norm-editor/.websocket': [ 'controller::socket:NormEditor', ], + '/editor/.websocket': [ + 'controller::socket:Editor', + ], + '/code/.websocket': [ + 'controller::socket:Code', + ], } }