Synchronized Editor: Store all events and allow multiple tabs
* This commit refactors the table used to store events. * We also use a UUID as session identifier in the synchronized editor to support multiple concurrent tabs opened by the same user. * Further, we renamed some methods to make them easier to distinguish.
This commit is contained in:

committed by
Sebastian Serth

parent
c42fb8fc09
commit
5dd6df9418
@ -1,12 +1,26 @@
|
||||
$(document).on('turbolinks:load', function () {
|
||||
|
||||
if (window.location.pathname.includes('/implement')) {
|
||||
function generateUUID() {
|
||||
// We decided to use this function instead of crypto.randomUUID() because it also supports older browser versions
|
||||
// https://caniuse.com/?search=createObjectURL
|
||||
return URL.createObjectURL(new Blob()).slice(-36)
|
||||
}
|
||||
|
||||
function is_other_user(user) {
|
||||
return !_.isEqual(current_user, user);
|
||||
}
|
||||
|
||||
function is_other_session(other_session_id) {
|
||||
return session_id !== other_session_id;
|
||||
}
|
||||
|
||||
const editor = $('#editor');
|
||||
const exercise_id = editor.data('exercise-id');
|
||||
const current_user_id = editor.data('user-id');
|
||||
const current_contributor_id = editor.data('contributor-id');
|
||||
const session_id = generateUUID();
|
||||
|
||||
if ($.isController('exercises') && current_user_id !== current_contributor_id) {
|
||||
if ($.isController('exercises') && current_user.id !== current_contributor_id) {
|
||||
|
||||
App.synchronized_editor = App.cable.subscriptions.create({
|
||||
channel: "SynchronizedEditorChannel", exercise_id: exercise_id
|
||||
@ -23,25 +37,29 @@ $(document).on('turbolinks:load', function () {
|
||||
|
||||
received(data) {
|
||||
// Called when there's incoming data on the websocket for this channel
|
||||
if (current_user_id !== data.current_user_id) {
|
||||
switch(data.command) {
|
||||
case 'editor_change':
|
||||
CodeOceanEditor.applyChanges(data.delta.data, data.active_file);
|
||||
break;
|
||||
case 'connection_change':
|
||||
CodeOceanEditor.showPartnersConnectionStatus(data.status, data.current_user_name);
|
||||
this.perform('send_hello');
|
||||
break;
|
||||
case 'hello':
|
||||
CodeOceanEditor.showPartnersConnectionStatus(data.status, data.current_user_name);
|
||||
break;
|
||||
}
|
||||
switch (data.action) {
|
||||
case 'editor_change':
|
||||
if (is_other_session(data.session_id)) {
|
||||
CodeOceanEditor.applyChanges(data.delta, data.active_file);
|
||||
}
|
||||
break;
|
||||
case 'connection_change':
|
||||
if (is_other_user(data.user)) {
|
||||
CodeOceanEditor.showPartnersConnectionStatus(data.status, data.user.displayname);
|
||||
this.perform('connection_status');
|
||||
}
|
||||
break;
|
||||
case 'connection_status':
|
||||
if (is_other_user(data.user)) {
|
||||
CodeOceanEditor.showPartnersConnectionStatus(data.status, data.user.displayname);
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
send_changes(delta, active_file) {
|
||||
const delta_with_user_id = {command: 'editor_change', current_user_id: current_user_id, active_file: active_file, delta: delta}
|
||||
this.perform('send_changes', {delta_with_user_id: delta_with_user_id});
|
||||
editor_change(delta, active_file) {
|
||||
const message = {session_id: session_id, active_file: active_file, delta: delta.data}
|
||||
this.perform('editor_change', message);
|
||||
},
|
||||
|
||||
is_connected() {
|
||||
|
@ -339,7 +339,7 @@ var CodeOceanEditor = {
|
||||
CodeOceanEditor.lastDeltaObject = null;
|
||||
return;
|
||||
}
|
||||
App.synchronized_editor?.send_changes(deltaObject, this.active_file);
|
||||
App.synchronized_editor?.editor_change(deltaObject, this.active_file);
|
||||
|
||||
// TODO: This is a workaround for a bug in Ace. Remove when upgrading Ace.
|
||||
this.handleUTF16Surrogates(deltaObject, session);
|
||||
|
@ -3,13 +3,19 @@
|
||||
class SynchronizedEditorChannel < ApplicationCable::Channel
|
||||
def subscribed
|
||||
stream_from specific_channel
|
||||
ActionCable.server.broadcast(specific_channel, {command: 'connection_change', status: 'connected', current_user_id: current_user.id, current_user_name: current_user.name})
|
||||
message = create_message('connection_change', 'connected')
|
||||
|
||||
Event::SynchronizedEditor.create_for_connection_change(message, current_user, programming_group)
|
||||
ActionCable.server.broadcast(specific_channel, message)
|
||||
end
|
||||
|
||||
def unsubscribed
|
||||
# Any cleanup needed when channel is unsubscribed
|
||||
stop_all_streams
|
||||
ActionCable.server.broadcast(specific_channel, {command: 'connection_change', status: 'disconnected', current_user_id: current_user.id, current_user_name: current_user.name})
|
||||
message = create_message('connection_change', 'disconnected')
|
||||
|
||||
Event::SynchronizedEditor.create_for_connection_change(message, current_user, programming_group)
|
||||
ActionCable.server.broadcast(specific_channel, message)
|
||||
end
|
||||
|
||||
def specific_channel
|
||||
@ -21,14 +27,22 @@ class SynchronizedEditorChannel < ApplicationCable::Channel
|
||||
current_contributor if current_contributor.programming_group?
|
||||
end
|
||||
|
||||
def send_changes(message)
|
||||
change = message['delta_with_user_id'].deep_symbolize_keys
|
||||
def editor_change(message)
|
||||
change = message.deep_symbolize_keys
|
||||
|
||||
Event::SynchronizedEditor.create_for_editor_change(change, current_user, programming_group)
|
||||
ActionCable.server.broadcast(specific_channel, change)
|
||||
end
|
||||
|
||||
def send_hello
|
||||
ActionCable.server.broadcast(specific_channel, {command: 'hello', status: 'connected', current_user_id: current_user.id, current_user_name: current_user.name})
|
||||
def connection_status
|
||||
ActionCable.server.broadcast(specific_channel, create_message('connection_status', 'connected'))
|
||||
end
|
||||
|
||||
def create_message(action, status)
|
||||
{
|
||||
action:,
|
||||
status:,
|
||||
user: current_user.to_page_context,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
@ -7,53 +7,51 @@ class Event::SynchronizedEditor < ApplicationRecord
|
||||
|
||||
belongs_to :programming_group
|
||||
belongs_to :study_group
|
||||
belongs_to :file, class_name: 'CodeOcean::File'
|
||||
belongs_to :file, class_name: 'CodeOcean::File', optional: true
|
||||
|
||||
enum command: {
|
||||
enum action: {
|
||||
editor_change: 0,
|
||||
connection_change: 1,
|
||||
hello: 2,
|
||||
### TODO: Kira's commands
|
||||
connection_status: 2,
|
||||
}, _prefix: true
|
||||
|
||||
enum status: {
|
||||
connected: 0,
|
||||
disconnected: 1,
|
||||
### TODO: connected, disconnected ...
|
||||
}, _prefix: true
|
||||
|
||||
enum action: {
|
||||
enum editor_action: {
|
||||
insertText: 0,
|
||||
insertLines: 1,
|
||||
removeText: 2,
|
||||
removeLines: 3,
|
||||
changeFold: 4,
|
||||
removeFold: 5,
|
||||
### TODO: AceEditor Actions: insertText, insertLines, removeText, removesLines, ...
|
||||
}, _prefix: true
|
||||
|
||||
validates :status, presence: true, if: -> { command_connection_change? }
|
||||
validates :action, presence: true, if: -> { command_editor_change? }
|
||||
validates :range_start_row, numericality: {only_integer: true, greater_than_or_equal_to: 0}, if: -> { command_editor_change? }
|
||||
validates :range_start_column, numericality: {only_integer: true, greater_than_or_equal_to: 0}, if: -> { command_editor_change? }
|
||||
validates :range_end_row, numericality: {only_integer: true, greater_than_or_equal_to: 0}, if: -> { command_editor_change? }
|
||||
validates :range_end_column, numericality: {only_integer: true, greater_than_or_equal_to: 0}, if: -> { command_editor_change? }
|
||||
validates :nl, inclusion: {in: %W[\n \r\n]}, if: -> { action_removeLines? }
|
||||
validates :status, presence: true, if: -> { action_connection_change? }
|
||||
validates :file_id, presence: true, if: -> { action_editor_change? }
|
||||
validates :editor_action, presence: true, if: -> { action_editor_change? }
|
||||
validates :range_start_row, numericality: {only_integer: true, greater_than_or_equal_to: 0}, if: -> { action_editor_change? }
|
||||
validates :range_start_column, numericality: {only_integer: true, greater_than_or_equal_to: 0}, if: -> { action_editor_change? }
|
||||
validates :range_end_row, numericality: {only_integer: true, greater_than_or_equal_to: 0}, if: -> { action_editor_change? }
|
||||
validates :range_end_column, numericality: {only_integer: true, greater_than_or_equal_to: 0}, if: -> { action_editor_change? }
|
||||
validates :nl, inclusion: {in: %W[\n \r\n]}, if: -> { editor_action_removeLines? }
|
||||
|
||||
validate :either_lines_or_text
|
||||
|
||||
def self.create_for_editor_change(event, user, programming_group)
|
||||
event_copy = event.deep_dup
|
||||
file = event_copy.delete(:active_file)
|
||||
delta = event_copy.delete(:delta)[:data]
|
||||
delta = event_copy.delete(:delta)
|
||||
range = delta.delete(:range)
|
||||
|
||||
create!(
|
||||
user:,
|
||||
programming_group:,
|
||||
study_group_id: user.current_study_group_id,
|
||||
command: event_copy.delete(:command),
|
||||
action: delta.delete(:action),
|
||||
action: event_copy.delete(:action),
|
||||
editor_action: delta.delete(:action),
|
||||
file_id: file[:id],
|
||||
range_start_row: range[:start][:row],
|
||||
range_start_column: range[:start][:column],
|
||||
@ -66,9 +64,19 @@ class Event::SynchronizedEditor < ApplicationRecord
|
||||
)
|
||||
end
|
||||
|
||||
def self.create_for_connection_change(message, user, programming_group)
|
||||
create!(
|
||||
user:,
|
||||
programming_group:,
|
||||
study_group_id: user.current_study_group_id,
|
||||
action: message[:action],
|
||||
status: message[:status]
|
||||
)
|
||||
end
|
||||
|
||||
def self.data_attribute(event, delta)
|
||||
event[:delta] = {data: delta} if delta.present?
|
||||
event.presence if event.present? # TODO: As of now, we are storing the `current_user_id` most of the times. Intended?
|
||||
event.presence if event.present? # TODO: As of now, we are storing the `session_id` most of the times. Intended?
|
||||
end
|
||||
private_class_method :data_attribute
|
||||
|
||||
|
@ -4,7 +4,7 @@
|
||||
- show_tips_interventions = @show_tips_interventions || "false"
|
||||
- hide_rfc_button = @hide_rfc_button || false
|
||||
|
||||
#editor.row data-exercise-id=@exercise.id data-message-depleted=t('exercises.editor.depleted') data-message-timeout=t('exercises.editor.timeout', permitted_execution_time: @exercise.execution_environment.permitted_execution_time) data-message-out-of-memory=t('exercises.editor.out_of_memory', memory_limit: @exercise.execution_environment.memory_limit) data-submissions-url=submissions_path data-user-id=current_user.id data-user-name=current_user.name data-contributor-id=current_contributor.id data-user-external-id=external_user_external_id data-working-times-url=working_times_exercise_path(@exercise) data-intervention-save-url=intervention_exercise_path(@exercise) data-rfc-interventions=show_rfc_interventions data-break-interventions=show_break_interventions data-tips-interventions=show_tips_interventions
|
||||
#editor.row data-exercise-id=@exercise.id data-message-depleted=t('exercises.editor.depleted') data-message-timeout=t('exercises.editor.timeout', permitted_execution_time: @exercise.execution_environment.permitted_execution_time) data-message-out-of-memory=t('exercises.editor.out_of_memory', memory_limit: @exercise.execution_environment.memory_limit) data-submissions-url=submissions_path data-contributor-id=current_contributor.id data-user-external-id=external_user_external_id data-working-times-url=working_times_exercise_path(@exercise) data-intervention-save-url=intervention_exercise_path(@exercise) data-rfc-interventions=show_rfc_interventions data-break-interventions=show_break_interventions data-tips-interventions=show_tips_interventions
|
||||
- unless @embed_options[:hide_sidebar]
|
||||
- additional_classes = 'sidebar-col'
|
||||
- if @tips.blank?
|
||||
|
@ -0,0 +1,10 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class RenameColumnsInEventsSynchronizedEditor < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
change_table :events_synchronized_editor do |t|
|
||||
t.rename :action, :editor_action
|
||||
t.rename :command, :action
|
||||
end
|
||||
end
|
||||
end
|
@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.0].define(version: 2023_09_05_190519) do
|
||||
ActiveRecord::Schema[7.0].define(version: 2023_09_06_163923) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_trgm"
|
||||
enable_extension "pgcrypto"
|
||||
@ -154,10 +154,10 @@ ActiveRecord::Schema[7.0].define(version: 2023_09_05_190519) do
|
||||
t.bigint "study_group_id", null: false
|
||||
t.string "user_type", null: false
|
||||
t.bigint "user_id", null: false
|
||||
t.integer "command", limit: 2, default: 0, null: false, comment: "Used as enum in Rails"
|
||||
t.integer "action", limit: 2, default: 0, null: false, comment: "Used as enum in Rails"
|
||||
t.integer "status", limit: 2, comment: "Used as enum in Rails"
|
||||
t.bigint "file_id"
|
||||
t.integer "action", limit: 2, comment: "Used as enum in Rails"
|
||||
t.integer "editor_action", limit: 2, comment: "Used as enum in Rails"
|
||||
t.integer "range_start_row"
|
||||
t.integer "range_start_column"
|
||||
t.integer "range_end_row"
|
||||
|
Reference in New Issue
Block a user